Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77ded23aef |
No files matched your search
@@ -9,10 +9,6 @@ XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
# Perplexity API Key (AI deck research & generation)
|
||||
# Get yours at: https://www.perplexity.ai/settings/api
|
||||
PPLX_API_KEY=your-perplexity-api-key-here
|
||||
# Perplexity model for deck research & generation (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL=sonar-pro
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER=local
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
|
||||
@@ -33,6 +33,26 @@ jobs:
|
||||
- name: Run tests
|
||||
run: pytest -v --tb=short tests/ || true
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Run frontend tests
|
||||
working-directory: frontend
|
||||
run: npx vitest run
|
||||
|
||||
java-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
+11
-96
@@ -9,8 +9,6 @@ calls the LLM, parses responses, and persists coaching sessions.
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict
|
||||
@@ -18,7 +16,6 @@ from typing import Optional, List, Dict
|
||||
from .config import (
|
||||
DECK_REPORTS_DIR, COACH_SESSIONS_DIR,
|
||||
MAX_UNDERPERFORMERS, MAX_CANDIDATES_PER_UNDERPERFORMER,
|
||||
COACH_PROVIDER, PPLX_MODEL,
|
||||
UNDERPERFORMER_IMPACT_THRESHOLD, ensure_dirs,
|
||||
)
|
||||
from .models import (
|
||||
@@ -31,18 +28,6 @@ from .prompt_template import build_system_prompt, build_user_prompt
|
||||
|
||||
logger = logging.getLogger("coach.service")
|
||||
|
||||
# SQLite persistence for coach sessions
|
||||
try:
|
||||
from services.database import (
|
||||
save_coach_session as _db_save_session,
|
||||
load_coach_session as _db_load_session,
|
||||
list_coach_sessions as _db_list_sessions,
|
||||
delete_coach_session as _db_delete_session,
|
||||
)
|
||||
_DB_AVAILABLE = True
|
||||
except ImportError:
|
||||
_DB_AVAILABLE = False
|
||||
|
||||
|
||||
class CoachService:
|
||||
"""
|
||||
@@ -70,7 +55,7 @@ class CoachService:
|
||||
if not report_path.exists():
|
||||
# Try case-insensitive match
|
||||
for f in DECK_REPORTS_DIR.glob("*.json"):
|
||||
if re.sub(r'[^a-z0-9]', '', f.stem.lower()) == re.sub(r'[^a-z0-9]', '', deck_id.lower()):
|
||||
if f.stem.lower() == deck_id.lower():
|
||||
report_path = f
|
||||
break
|
||||
|
||||
@@ -95,62 +80,15 @@ class CoachService:
|
||||
# ── Coach Session I/O ──────────────────────────────────────
|
||||
|
||||
def save_session(self, session: CoachSession) -> Path:
|
||||
"""Persist a coaching session to disk and SQLite."""
|
||||
# 1. Write JSON file (backward compat)
|
||||
"""Persist a coaching session to disk."""
|
||||
COACH_SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
path = COACH_SESSIONS_DIR / f"{session.sessionId}.json"
|
||||
with open(path, "w") as f:
|
||||
f.write(session.model_dump_json(indent=2))
|
||||
# 2. Write to SQLite
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
_db_save_session({
|
||||
"session_id": session.sessionId,
|
||||
"deck_id": session.deckId,
|
||||
"timestamp": session.timestamp,
|
||||
"model_used": session.modelUsed or "",
|
||||
"prompt_tokens": session.promptTokens or 0,
|
||||
"completion_tokens": session.completionTokens or 0,
|
||||
"summary": session.summary or "",
|
||||
"goals_json": json.dumps(session.goals.model_dump() if session.goals else {}),
|
||||
"cuts_json": json.dumps([c.model_dump() for c in session.suggestedCuts]),
|
||||
"adds_json": json.dumps([a.model_dump() for a in session.suggestedAdds]),
|
||||
"heuristic_hints_json": json.dumps(session.heuristicHints or []),
|
||||
"mana_base_advice": session.manaBaseAdvice or "",
|
||||
"raw_text": session.rawTextExplanation or "",
|
||||
})
|
||||
logger.info("Coach session persisted to SQLite: %s", session.sessionId)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to persist session to SQLite: %s", e)
|
||||
return path
|
||||
|
||||
|
||||
def load_session(self, session_id: str) -> Optional[CoachSession]:
|
||||
"""Load a coaching session from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
row = _db_load_session(session_id)
|
||||
if row:
|
||||
data = dict(row)
|
||||
return CoachSession(
|
||||
sessionId=data["session_id"],
|
||||
deckId=data["deck_id"],
|
||||
timestamp=data["timestamp"],
|
||||
modelUsed=data.get("model_used", ""),
|
||||
promptTokens=data.get("prompt_tokens", 0),
|
||||
completionTokens=data.get("completion_tokens", 0),
|
||||
summary=data.get("summary", ""),
|
||||
goals=json.loads(data["goals_json"]) if data.get("goals_json") and data["goals_json"] != "{}" else None,
|
||||
suggestedCuts=[SuggestedCut(**c) for c in json.loads(data.get("cuts_json", "[]"))],
|
||||
suggestedAdds=[SuggestedAdd(**a) for a in json.loads(data.get("adds_json", "[]"))],
|
||||
heuristicHints=json.loads(data.get("heuristic_hints_json", "[]")),
|
||||
manaBaseAdvice=data.get("mana_base_advice", ""),
|
||||
rawTextExplanation=data.get("raw_text", ""),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("SQLite load failed for %s: %s", session_id, e)
|
||||
# Fallback to disk
|
||||
"""Load a coaching session from disk."""
|
||||
path = COACH_SESSIONS_DIR / f"{session_id}.json"
|
||||
if not path.exists():
|
||||
return None
|
||||
@@ -162,18 +100,8 @@ class CoachService:
|
||||
logger.error("Failed to load session %s: %s", session_id, e)
|
||||
return None
|
||||
|
||||
|
||||
def list_sessions(self, deck_id: str = None) -> List[dict]:
|
||||
"""List coaching sessions from SQLite, falling back to disk."""
|
||||
# Try SQLite first
|
||||
if _DB_AVAILABLE:
|
||||
try:
|
||||
rows = _db_list_sessions(deck_id)
|
||||
if rows is not None:
|
||||
return rows
|
||||
except Exception as e:
|
||||
logger.warning("SQLite list_sessions failed: %s", e)
|
||||
# Fallback to disk
|
||||
"""List all coaching sessions, optionally filtered by deck."""
|
||||
if not COACH_SESSIONS_DIR.exists():
|
||||
return []
|
||||
sessions = []
|
||||
@@ -195,7 +123,6 @@ class CoachService:
|
||||
continue
|
||||
return sessions
|
||||
|
||||
|
||||
# ── Main Coaching Pipeline ─────────────────────────────────
|
||||
|
||||
async def run_coaching_session(self, deck_id: str,
|
||||
@@ -338,25 +265,13 @@ class CoachService:
|
||||
"""Check the health of all coach subsystems."""
|
||||
status = CoachStatus()
|
||||
|
||||
# Check LLM / Perplexity provider
|
||||
if COACH_PROVIDER == "perplexity":
|
||||
pplx_key = os.environ.get("PPLX_API_KEY", "")
|
||||
if pplx_key:
|
||||
status.llmConnected = True
|
||||
status.llmModel = f"Perplexity ({PPLX_MODEL})"
|
||||
status.llmModels = [PPLX_MODEL]
|
||||
else:
|
||||
status.llmConnected = False
|
||||
status.llmModel = None
|
||||
status.llmModels = []
|
||||
status.error = "Perplexity API key not configured. Set PPLX_API_KEY env var."
|
||||
else:
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
status.llmModel = llm_status.get("active_model")
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "Local LLM not reachable")
|
||||
# Check LLM
|
||||
llm_status = self.llm.check_connection()
|
||||
status.llmConnected = llm_status.get("connected", False)
|
||||
status.llmModel = llm_status.get("active_model")
|
||||
status.llmModels = llm_status.get("models", [])
|
||||
if not status.llmConnected:
|
||||
status.error = llm_status.get("error", "LM Studio not reachable")
|
||||
|
||||
# Check embeddings
|
||||
status.embeddingsLoaded = self.embeddings.loaded
|
||||
|
||||
+2
-6
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
LAB_ROOT = Path(os.environ.get("COMMANDER_LAB_ROOT", Path(__file__).parent.parent))
|
||||
|
||||
# ── Ollama (OpenAI-compatible API) ──────────────────────
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://localhost:11434/v1")
|
||||
LLM_URL = os.environ.get("LLM_URL", "http://192.168.0.240:11434/v1")
|
||||
LLM_MODEL = os.environ.get("LLM_MODEL", "gpt-oss:20b")
|
||||
LLM_TIMEOUT = int(os.environ.get("LLM_TIMEOUT", "360")) # seconds
|
||||
LLM_MAX_RETRIES = 3
|
||||
@@ -44,11 +44,7 @@ DEFAULT_MAX_TOKENS = 8192
|
||||
DECK_GEN_PROVIDER = os.environ.get("DECK_GEN_PROVIDER", "local")
|
||||
# Model for deck generation (sonar = fast/$0.004, sonar-pro = deep/$0.04)
|
||||
DECK_GEN_MODEL = os.environ.get("DECK_GEN_MODEL", "gpt-oss:20b")
|
||||
# Perplexity cloud model (sonar / sonar-pro / sonar-deep-research)
|
||||
PPLX_MODEL = os.environ.get("PPLX_MODEL", "sonar-pro")
|
||||
# Coach chat provider: "local" (Ollama) or "perplexity" (Perplexity API)
|
||||
COACH_PROVIDER = os.environ.get("COACH_PROVIDER", "local")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://localhost:11434/v1")
|
||||
DECK_GEN_BASE_URL = os.environ.get("DECK_GEN_BASE_URL", "http://192.168.0.240:11434/v1")
|
||||
DECK_GEN_TEMPERATURE = float(os.environ.get("DECK_GEN_TEMPERATURE", "0.2"))
|
||||
DECK_GEN_MAX_TOKENS = int(os.environ.get("DECK_GEN_MAX_TOKENS", "16384"))
|
||||
|
||||
|
||||
+41
-237
@@ -9,11 +9,6 @@ Data source: https://huggingface.co/datasets/minimaxir/mtg-embeddings
|
||||
Format: Parquet with 768-dim float32 embeddings per card (~32K cards)
|
||||
Model: Alibaba-NLP/gte-modernbert-base
|
||||
|
||||
Unknown cards (e.g. newly printed sets) are fetched from Scryfall,
|
||||
embedded on-the-fly using the same model, and persisted to a local
|
||||
custom cache (embeddings/mtg_embeddings_custom.npz) so they survive
|
||||
restarts without re-hitting Scryfall.
|
||||
|
||||
Uses NumPy brute-force cosine similarity — fast enough for ~32K cards (<100ms).
|
||||
"""
|
||||
|
||||
@@ -21,11 +16,9 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.parse import quote
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -39,16 +32,8 @@ HF_PARQUET_URL = (
|
||||
"resolve/main/mtg_embeddings.parquet"
|
||||
)
|
||||
|
||||
# ── Scryfall API ───────────────────────────────────────────
|
||||
SCRYFALL_NAMED_URL = "https://api.scryfall.com/cards/named?fuzzy={}"
|
||||
|
||||
# ── Custom cache for on-the-fly embedded cards ─────────────
|
||||
CUSTOM_CACHE_PATH = EMBEDDINGS_DIR / "mtg_embeddings_custom.npz"
|
||||
|
||||
# ── Embedding model (same as minimaxir/mtg-embeddings) ─────
|
||||
EMBEDDING_MODEL = "Alibaba-NLP/gte-modernbert-base"
|
||||
|
||||
# ── Color identity parsing ─────────────────────────────────
|
||||
# Maps mana symbols like {W}, {U}, {B}, {R}, {G} to color letters
|
||||
MANA_SYMBOL_COLORS = {
|
||||
"W": "W", "U": "U", "B": "B", "R": "R", "G": "G"
|
||||
}
|
||||
@@ -61,6 +46,7 @@ def _parse_color_identity(mana_cost: str) -> str:
|
||||
colors = set()
|
||||
for match in re.finditer(r'\{([WUBRG])\}', mana_cost or ""):
|
||||
colors.add(match.group(1))
|
||||
# Sort in WUBRG order
|
||||
order = "WUBRG"
|
||||
return "".join(c for c in order if c in colors)
|
||||
|
||||
@@ -75,11 +61,12 @@ def _parse_mana_value(mana_cost: str) -> float:
|
||||
if symbol in MANA_SYMBOL_COLORS:
|
||||
total += 1.0
|
||||
elif symbol == "X":
|
||||
pass
|
||||
pass # X = 0 for CMC purposes
|
||||
else:
|
||||
try:
|
||||
total += float(symbol)
|
||||
except ValueError:
|
||||
# Hybrid/phyrexian symbols count as 1
|
||||
total += 1.0
|
||||
return total
|
||||
|
||||
@@ -88,42 +75,7 @@ def _is_color_subset(card_colors: str, deck_colors: List[str]) -> bool:
|
||||
"""Check if card's color identity is within deck's color identity."""
|
||||
deck_set = set(deck_colors)
|
||||
card_set = set(card_colors)
|
||||
return card_set.issubset(deck_set) or len(card_set) == 0
|
||||
|
||||
|
||||
def _fetch_scryfall_card(card_name: str) -> Optional[dict]:
|
||||
"""Fetch card data from Scryfall by fuzzy name match.
|
||||
|
||||
Returns a dict with keys: name, mana_cost, type_line, oracle_text,
|
||||
color_identity. Returns None on any error.
|
||||
"""
|
||||
url = SCRYFALL_NAMED_URL.format(quote(card_name))
|
||||
try:
|
||||
req = Request(url, headers={"User-Agent": "commander-ai-lab/1.0"})
|
||||
with urlopen(req, timeout=10) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
return {
|
||||
"name": data.get("name", card_name),
|
||||
"mana_cost": data.get("mana_cost", ""),
|
||||
"type_line": data.get("type_line", ""),
|
||||
"oracle_text": data.get("oracle_text", ""),
|
||||
"color_identity": "".join(data.get("color_identity", [])),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("Scryfall lookup failed for '%s': %s", card_name, e)
|
||||
return None
|
||||
|
||||
|
||||
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)
|
||||
return card_set.issubset(deck_set) or len(card_set) == 0 # colorless always allowed
|
||||
|
||||
|
||||
class CardMatch:
|
||||
@@ -162,17 +114,15 @@ class MTGEmbeddingIndex:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.names: np.ndarray = None
|
||||
self.colors: np.ndarray = None
|
||||
self.mana_values: np.ndarray = None
|
||||
self.types: np.ndarray = None
|
||||
self.mana_costs: np.ndarray = None
|
||||
self.texts: np.ndarray = None
|
||||
self.vectors: np.ndarray = None
|
||||
self.names: np.ndarray = None # (N,) string array
|
||||
self.colors: np.ndarray = None # (N,) string array (e.g., "WU", "BRG")
|
||||
self.mana_values: np.ndarray = None # (N,) float array
|
||||
self.types: np.ndarray = None # (N,) string array
|
||||
self.mana_costs: np.ndarray = None # (N,) string array
|
||||
self.texts: np.ndarray = None # (N,) string array
|
||||
self.vectors: np.ndarray = None # (N, 768) float32, L2-normalized
|
||||
self._name_to_idx: dict = {}
|
||||
self._loaded = False
|
||||
self._sentence_model = None # lazy-loaded only when needed
|
||||
self._custom_dirty = False # tracks whether custom cache needs saving
|
||||
|
||||
@property
|
||||
def loaded(self) -> bool:
|
||||
@@ -202,7 +152,10 @@ class MTGEmbeddingIndex:
|
||||
return EMBEDDINGS_PARQUET
|
||||
|
||||
def convert_parquet_to_npz(self) -> Path:
|
||||
"""Convert the Parquet file to a compact NPZ for fast loading."""
|
||||
"""
|
||||
Convert the Parquet file to a compact NPZ for fast loading.
|
||||
Requires pandas and pyarrow.
|
||||
"""
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError:
|
||||
@@ -216,11 +169,14 @@ class MTGEmbeddingIndex:
|
||||
types = df["type"].fillna("").values.astype(str)
|
||||
texts = df["text"].fillna("").values.astype(str)
|
||||
|
||||
# Parse color identities from mana costs
|
||||
colors = np.array([_parse_color_identity(mc) for mc in mana_costs])
|
||||
mana_values = np.array([_parse_mana_value(mc) for mc in mana_costs], dtype=np.float32)
|
||||
|
||||
# Extract embedding vectors
|
||||
embeddings = np.vstack(df["embedding"].to_numpy()).astype(np.float32)
|
||||
|
||||
# L2 normalize for cosine similarity via dot product
|
||||
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1.0
|
||||
embeddings = embeddings / norms
|
||||
@@ -242,27 +198,25 @@ class MTGEmbeddingIndex:
|
||||
return EMBEDDINGS_NPZ
|
||||
|
||||
def load(self, force_download: bool = False) -> bool:
|
||||
"""Load embeddings from NPZ (fast) or fall back to downloading
|
||||
"""
|
||||
Load embeddings from NPZ (fast) or fall back to downloading
|
||||
and converting from Parquet.
|
||||
"""
|
||||
if self._loaded and not force_download:
|
||||
return True
|
||||
|
||||
# Try NPZ first (fast path)
|
||||
if EMBEDDINGS_NPZ.exists() and not force_download:
|
||||
ok = self._load_npz()
|
||||
else:
|
||||
try:
|
||||
self.download_parquet(force=force_download)
|
||||
self.convert_parquet_to_npz()
|
||||
ok = self._load_npz()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load embeddings: %s", e)
|
||||
return False
|
||||
return self._load_npz()
|
||||
|
||||
if ok:
|
||||
self._load_custom_cache()
|
||||
|
||||
return ok
|
||||
# Download and convert
|
||||
try:
|
||||
self.download_parquet(force=force_download)
|
||||
self.convert_parquet_to_npz()
|
||||
return self._load_npz()
|
||||
except Exception as e:
|
||||
logger.error("Failed to load embeddings: %s", e)
|
||||
return False
|
||||
|
||||
def _load_npz(self) -> bool:
|
||||
"""Load from pre-built NPZ file."""
|
||||
@@ -276,6 +230,7 @@ class MTGEmbeddingIndex:
|
||||
self.texts = data.get("texts", np.array([""] * len(self.names)))
|
||||
self.vectors = data["vectors"]
|
||||
|
||||
# Build name lookup
|
||||
self._name_to_idx = {
|
||||
name.lower(): i for i, name in enumerate(self.names)
|
||||
}
|
||||
@@ -286,169 +241,12 @@ class MTGEmbeddingIndex:
|
||||
logger.error("Failed to load NPZ: %s", e)
|
||||
return False
|
||||
|
||||
# ── Custom cache ──────────────────────────────────────────
|
||||
|
||||
def _load_custom_cache(self) -> None:
|
||||
"""Merge on-the-fly embedded cards from the custom cache into the index."""
|
||||
if not CUSTOM_CACHE_PATH.exists():
|
||||
return
|
||||
try:
|
||||
data = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
c_names = data["names"]
|
||||
# Only add cards that aren't already in the main index
|
||||
new_mask = np.array(
|
||||
[n.lower() not in self._name_to_idx for n in c_names]
|
||||
)
|
||||
if not new_mask.any():
|
||||
return
|
||||
self._append_cards(
|
||||
c_names[new_mask],
|
||||
data["colors"][new_mask],
|
||||
data["mana_values"][new_mask],
|
||||
data["types"][new_mask],
|
||||
data["mana_costs"][new_mask],
|
||||
data["texts"][new_mask],
|
||||
data["vectors"][new_mask],
|
||||
)
|
||||
logger.info("Loaded %d custom-cached card embeddings", new_mask.sum())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to load custom embedding cache: %s", e)
|
||||
|
||||
def _save_custom_cache(self) -> None:
|
||||
"""Persist the custom cache NPZ (only cards not in the base dataset)."""
|
||||
if not self._custom_dirty:
|
||||
return
|
||||
try:
|
||||
EMBEDDINGS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Collect indices that correspond to custom-added cards
|
||||
# We track these by loading the base count at load time — simpler:
|
||||
# just re-save the full custom cache from CUSTOM_CACHE_PATH if it
|
||||
# exists, merging with any new cards we've added this session.
|
||||
existing_names: set = set()
|
||||
existing: dict = {}
|
||||
if CUSTOM_CACHE_PATH.exists():
|
||||
try:
|
||||
d = np.load(str(CUSTOM_CACHE_PATH), allow_pickle=True)
|
||||
for n in d["names"]:
|
||||
existing_names.add(n.lower())
|
||||
existing = dict(d)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Find names in our live index that aren't in the base NPZ
|
||||
base_data = np.load(str(EMBEDDINGS_NPZ), allow_pickle=True)
|
||||
base_names = set(n.lower() for n in base_data["names"])
|
||||
custom_mask = np.array(
|
||||
[n.lower() not in base_names for n in self.names]
|
||||
)
|
||||
if not custom_mask.any():
|
||||
return
|
||||
|
||||
np.savez_compressed(
|
||||
str(CUSTOM_CACHE_PATH),
|
||||
names=self.names[custom_mask],
|
||||
colors=self.colors[custom_mask],
|
||||
mana_values=self.mana_values[custom_mask],
|
||||
types=self.types[custom_mask],
|
||||
mana_costs=self.mana_costs[custom_mask],
|
||||
texts=self.texts[custom_mask],
|
||||
vectors=self.vectors[custom_mask],
|
||||
)
|
||||
self._custom_dirty = False
|
||||
logger.info("Saved custom embedding cache: %d cards", custom_mask.sum())
|
||||
except Exception as e:
|
||||
logger.warning("Failed to save custom embedding cache: %s", e)
|
||||
|
||||
def _append_cards(
|
||||
self,
|
||||
names: np.ndarray,
|
||||
colors: np.ndarray,
|
||||
mana_values: np.ndarray,
|
||||
types: np.ndarray,
|
||||
mana_costs: np.ndarray,
|
||||
texts: np.ndarray,
|
||||
vectors: np.ndarray,
|
||||
) -> None:
|
||||
"""Append new card arrays to the live in-memory index."""
|
||||
start_idx = len(self.names)
|
||||
self.names = np.concatenate([self.names, names])
|
||||
self.colors = np.concatenate([self.colors, colors])
|
||||
self.mana_values = np.concatenate([self.mana_values, mana_values])
|
||||
self.types = np.concatenate([self.types, types])
|
||||
self.mana_costs = np.concatenate([self.mana_costs, mana_costs])
|
||||
self.texts = np.concatenate([self.texts, texts])
|
||||
self.vectors = np.concatenate([self.vectors, vectors], axis=0)
|
||||
for i, name in enumerate(names):
|
||||
self._name_to_idx[name.lower()] = start_idx + i
|
||||
|
||||
# ── Sentence model (lazy) ─────────────────────────────────
|
||||
|
||||
def _get_sentence_model(self):
|
||||
"""Lazy-load the sentence-transformers model on first cache miss."""
|
||||
if self._sentence_model is None:
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
logger.info("Loading embedding model %s (first cache miss)...", EMBEDDING_MODEL)
|
||||
self._sentence_model = SentenceTransformer(EMBEDDING_MODEL)
|
||||
logger.info("Embedding model loaded.")
|
||||
except Exception as e:
|
||||
logger.error("Failed to load SentenceTransformer: %s", e)
|
||||
return None
|
||||
return self._sentence_model
|
||||
|
||||
# ── On-the-fly Scryfall fallback ──────────────────────────
|
||||
|
||||
def _fetch_and_embed_card(self, card_name: str) -> Optional[np.ndarray]:
|
||||
"""Fetch card from Scryfall, embed it, append to index, persist to cache.
|
||||
|
||||
Returns the L2-normalized embedding vector on success, None on failure.
|
||||
"""
|
||||
logger.info("Fetching unknown card from Scryfall: '%s'", card_name)
|
||||
card_data = _fetch_scryfall_card(card_name)
|
||||
if card_data is None:
|
||||
return None
|
||||
|
||||
model = self._get_sentence_model()
|
||||
if model is None:
|
||||
return None
|
||||
|
||||
text = _build_card_text(card_data)
|
||||
vec = model.encode(text, normalize_embeddings=True).astype(np.float32)
|
||||
# Ensure shape (1, 768)
|
||||
vec = vec.reshape(1, -1)
|
||||
|
||||
resolved_name = card_data["name"]
|
||||
mana_cost = card_data.get("mana_cost", "")
|
||||
color_identity = card_data.get("color_identity", "") or _parse_color_identity(mana_cost)
|
||||
|
||||
self._append_cards(
|
||||
names=np.array([resolved_name]),
|
||||
colors=np.array([color_identity]),
|
||||
mana_values=np.array([_parse_mana_value(mana_cost)], dtype=np.float32),
|
||||
types=np.array([card_data.get("type_line", "")]),
|
||||
mana_costs=np.array([mana_cost]),
|
||||
texts=np.array([card_data.get("oracle_text", "")]),
|
||||
vectors=vec,
|
||||
)
|
||||
|
||||
self._custom_dirty = True
|
||||
self._save_custom_cache()
|
||||
logger.info("Embedded and cached new card: '%s'", resolved_name)
|
||||
return self.vectors[self._name_to_idx[resolved_name.lower()]]
|
||||
|
||||
# ── Public API ────────────────────────────────────────────
|
||||
|
||||
def get_card_vector(self, card_name: str) -> Optional[np.ndarray]:
|
||||
"""Get the embedding vector for a card by name (case-insensitive).
|
||||
|
||||
On a cache miss, fetches from Scryfall and embeds on the fly.
|
||||
"""
|
||||
"""Get the embedding vector for a card by name (case-insensitive)."""
|
||||
idx = self._name_to_idx.get(card_name.lower())
|
||||
if idx is not None:
|
||||
return self.vectors[idx]
|
||||
|
||||
# Cache miss — try Scryfall fallback
|
||||
return self._fetch_and_embed_card(card_name)
|
||||
return None
|
||||
|
||||
def search_similar(self, query_card: str,
|
||||
color_filter: List[str] = None,
|
||||
@@ -476,32 +274,38 @@ class MTGEmbeddingIndex:
|
||||
|
||||
query_vec = self.get_card_vector(query_card)
|
||||
if query_vec is None:
|
||||
logger.warning("Card not found in embeddings and Scryfall fallback failed: %s", query_card)
|
||||
logger.warning("Card not found in embeddings: %s", query_card)
|
||||
return []
|
||||
|
||||
# Compute similarities (dot product on normalized vectors = cosine similarity)
|
||||
similarities = self.vectors @ query_vec
|
||||
|
||||
# Build exclusion set
|
||||
exclude_set = set()
|
||||
exclude_set.add(query_card.lower())
|
||||
if exclude_cards:
|
||||
exclude_set.update(name.lower() for name in exclude_cards)
|
||||
|
||||
# Filter and collect results
|
||||
results = []
|
||||
for idx in np.argsort(similarities)[::-1]:
|
||||
name = str(self.names[idx])
|
||||
if name.lower() in exclude_set:
|
||||
continue
|
||||
|
||||
# Color filter
|
||||
if color_filter:
|
||||
card_colors = str(self.colors[idx])
|
||||
if not _is_color_subset(card_colors, color_filter):
|
||||
continue
|
||||
|
||||
# Type filter
|
||||
if type_filter:
|
||||
card_type = str(self.types[idx])
|
||||
if type_filter.lower() not in card_type.lower():
|
||||
continue
|
||||
|
||||
# Mana value range filter
|
||||
if mana_range:
|
||||
mv = float(self.mana_values[idx])
|
||||
if mv < mana_range[0] or mv > mana_range[1]:
|
||||
|
||||
@@ -38,7 +38,7 @@ def generate_deck_reports(results_dir: str, reports_dir: str = None) -> List[str
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Load all batch results
|
||||
batch_files = sorted(results_path.glob("ml-decision-*.json"))
|
||||
batch_files = sorted(results_path.glob("batch-*.json"))
|
||||
if not batch_files:
|
||||
logger.info("No batch result files found in %s", results_dir)
|
||||
return []
|
||||
@@ -108,7 +108,7 @@ def generate_single_deck_report(batch_json_path: str, reports_dir: str = None) -
|
||||
# Also load existing batch results for comprehensive reports
|
||||
results_dir = Path(batch_json_path).parent
|
||||
all_batches = []
|
||||
for bf in sorted(results_dir.glob("ml-decision-*.json")):
|
||||
for bf in sorted(results_dir.glob("batch-*.json")):
|
||||
try:
|
||||
with open(bf, "r", encoding="utf-8") as f:
|
||||
all_batches.append(json.load(f))
|
||||
|
||||
@@ -256,19 +256,6 @@ class DeckGeneratorV3:
|
||||
# Step 5b: Fill basic lands to hit exactly 100 cards
|
||||
cards_with_status = self._fill_basic_lands(cards_with_status, color_identity)
|
||||
|
||||
# Step 5c: Secondary safety-net deduplication
|
||||
seen = {}
|
||||
deduped = []
|
||||
for c in cards_with_status:
|
||||
key = c.name.lower()
|
||||
if key in seen:
|
||||
deduped[seen[key]].count += c.count
|
||||
logger.warning("Secondary dedupe caught duplicate: '%s'", c.name)
|
||||
else:
|
||||
seen[key] = len(deduped)
|
||||
deduped.append(c)
|
||||
cards_with_status = deduped
|
||||
|
||||
# Step 6: Compute stats
|
||||
stats = self._compute_stats(cards_with_status)
|
||||
|
||||
@@ -297,26 +284,15 @@ class DeckGeneratorV3:
|
||||
"""
|
||||
Cross-reference generated cards against the collection DB.
|
||||
Sets owned/scryfall_id/owned_qty and real price.
|
||||
Deduplicates cards by name (case-insensitive) — if the LLM
|
||||
returns the same card twice, the counts are merged.
|
||||
"""
|
||||
conn = self.db_conn_factory()
|
||||
enriched = []
|
||||
seen_names: dict[str, int] = {} # name_lower → index in enriched
|
||||
|
||||
for card in cards:
|
||||
name = card.get("name", "")
|
||||
if not name:
|
||||
continue
|
||||
|
||||
name_lower = name.lower()
|
||||
|
||||
if name_lower in seen_names:
|
||||
idx = seen_names[name_lower]
|
||||
enriched[idx].count += card.get("count", 1)
|
||||
logger.warning("Duplicate card '%s' merged (LLM returned it twice)", name)
|
||||
continue
|
||||
|
||||
# Look up in collection
|
||||
row = conn.execute(
|
||||
"SELECT name, scryfall_id, quantity, tcg_price, type_line, cmc "
|
||||
@@ -342,7 +318,6 @@ class DeckGeneratorV3:
|
||||
scryfall_id=row["scryfall_id"] if row else "",
|
||||
status="owned" if is_owned else "missing",
|
||||
)
|
||||
seen_names[name_lower] = len(enriched)
|
||||
enriched.append(enriched_card)
|
||||
|
||||
return enriched
|
||||
|
||||
+17
-11
@@ -22,10 +22,15 @@ 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 services.database import init_collection_db
|
||||
from services.logging import setup_logging
|
||||
from services.precon_service import PRECON_INDEX, download_precon_database
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
setup_logging,
|
||||
init_collection_db,
|
||||
download_precon_database,
|
||||
load_commander_meta,
|
||||
PRECON_INDEX,
|
||||
COMMANDER_META,
|
||||
)
|
||||
from routes.collection import router as collection_router
|
||||
from routes.deckbuilder import router as deckbuilder_router
|
||||
from routes.precon import router as precon_router
|
||||
@@ -40,7 +45,7 @@ from routes.ml import router as ml_router
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
# ── CORS origins (env-configurable) ────────────────────────────
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080"
|
||||
_DEFAULT_ORIGINS = "http://localhost:5173,http://localhost:8080,http://192.168.0.240:5173,http://192.168.0.240:8080"
|
||||
_allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
@@ -75,14 +80,15 @@ async def health_check():
|
||||
@app.on_event("startup")
|
||||
async def _on_startup():
|
||||
"""Ensure DB and precon index are ready (supports uvicorn --reload)."""
|
||||
import routes.shared as _shared
|
||||
# Ensure precon_dir is resolved even when started via uvicorn directly
|
||||
if not CFG.precon_dir:
|
||||
CFG.precon_dir = _resolve_precon_dir()
|
||||
init_collection_db()
|
||||
if not PRECON_INDEX:
|
||||
download_precon_database()
|
||||
if not COMMANDER_META:
|
||||
load_commander_meta()
|
||||
_shared.init_collection_db()
|
||||
if not _shared.PRECON_INDEX:
|
||||
_shared.download_precon_database()
|
||||
if not _shared.COMMANDER_META:
|
||||
_shared.load_commander_meta()
|
||||
|
||||
# ── Static UI ──────────────────────────────────────────────────
|
||||
_legacy_ui_dir = Path(__file__).parent / "ui"
|
||||
@@ -282,7 +288,7 @@ def main():
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
from routes.shared import get_java17
|
||||
j17 = get_java17()
|
||||
|
||||
log.info("Commander AI Lab — API Server v3.0.0")
|
||||
|
||||
+3
-49
@@ -155,25 +155,15 @@ class StateDimensions:
|
||||
2 players = 28 global features
|
||||
+ turn_number = 1
|
||||
Total global = 29
|
||||
|
||||
Zone embedding boundary (architectural decision — Phase 5 follow-up):
|
||||
Zone dims are **always** 768 per zone slot, zero-padded when a zone
|
||||
is empty (no cards). The fixed 6144-dim zone block is the contract
|
||||
between ForgeEpisodeGenerator (upstream) and the policy network
|
||||
(downstream). All downstream consumers (PolicyNetwork, PPOTrainer,
|
||||
dataset builder) depend on this shape. If a future change adds
|
||||
attention pooling or variable-length zone representations, the
|
||||
boundary must be updated here *and* in StateEncoder._encode_zones.
|
||||
"""
|
||||
card_embedding_dim: int = 768 # From mtg-embeddings
|
||||
zone_pool_dim: int = 768 # Mean-pooled per zone (zero-vec when empty)
|
||||
zone_pool_dim: int = 768 # Mean-pooled per zone
|
||||
|
||||
# Zones per player: hand, battlefield, graveyard, command_zone
|
||||
zones_per_player: int = 4
|
||||
num_players: int = 2 # 1v1
|
||||
|
||||
# Zone vectors: 4 zones × 2 players × 768 = 6144
|
||||
# This is a fixed-size block; empty zones produce zero vectors.
|
||||
total_zone_dim: int = 4 * 2 * 768 # 6144
|
||||
|
||||
# Global scalar features
|
||||
@@ -278,10 +268,6 @@ class DistillationDefaults:
|
||||
|
||||
These are consumed by ml.training.distillation_loop.DistillationConfig
|
||||
when no explicit override is provided.
|
||||
|
||||
Default weights: Forge-only (forge_weight=1.0, ppo_weight=0.0).
|
||||
The baseline trains exclusively on Forge simulation data. Use
|
||||
``MIXED_MODE_PRESETS`` below for blended Forge+PPO configurations.
|
||||
"""
|
||||
max_iterations: int = 10
|
||||
convergence_window: int = 3
|
||||
@@ -295,9 +281,9 @@ class DistillationDefaults:
|
||||
ppo_entropy_coeff: float = 0.01
|
||||
ppo_eval_episodes: int = 100
|
||||
|
||||
# Dataset mixing — Forge-only by default (Phase 5 follow-up decision)
|
||||
# Dataset mixing
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.0
|
||||
ppo_weight: float = 0.5
|
||||
min_reward_threshold: float = 0.0
|
||||
|
||||
# Quality gate thresholds
|
||||
@@ -316,35 +302,3 @@ class DistillationDefaults:
|
||||
|
||||
|
||||
DISTILLATION_DEFAULTS = DistillationDefaults()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Mixed-Mode Presets (Phase 5 follow-up)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
# Pre-defined Forge/PPO weight combinations for common scenarios.
|
||||
# Select a preset by name when starting a distillation run to
|
||||
# override the Forge-only defaults without specifying raw weights.
|
||||
#
|
||||
# "forge_only" — baseline: 100% Forge data, ignore PPO
|
||||
# "forge_90_10" — light PPO blending: 90% Forge, 10% PPO
|
||||
# "forge_80_20" — heavier PPO blending: 80% Forge, 20% PPO
|
||||
MIXED_MODE_PRESETS: Dict[str, Dict[str, float]] = {
|
||||
"forge_only": {"forge_weight": 1.0, "ppo_weight": 0.0},
|
||||
"forge_90_10": {"forge_weight": 1.0, "ppo_weight": 0.11}, # ~10% PPO
|
||||
"forge_80_20": {"forge_weight": 1.0, "ppo_weight": 0.25}, # ~20% PPO
|
||||
}
|
||||
|
||||
|
||||
def get_preset_weights(preset_name: str) -> Dict[str, float]:
|
||||
"""Look up a mixed-mode preset by name.
|
||||
|
||||
Returns a dict with ``forge_weight`` and ``ppo_weight`` keys.
|
||||
Raises ``KeyError`` if the preset name is unknown.
|
||||
"""
|
||||
if preset_name not in MIXED_MODE_PRESETS:
|
||||
raise KeyError(
|
||||
f"Unknown preset '{preset_name}'. "
|
||||
f"Available: {list(MIXED_MODE_PRESETS.keys())}"
|
||||
)
|
||||
return dict(MIXED_MODE_PRESETS[preset_name])
|
||||
@@ -63,10 +63,10 @@ class DatasetConfig:
|
||||
max_samples: Optional[int] = None
|
||||
|
||||
# Source weights control sampling ratios between Forge and PPO data.
|
||||
# Default: Forge-only (ppo=0.0). Use MIXED_MODE_PRESETS from
|
||||
# ml.config.scope for blended configurations (e.g. 90/10, 80/20).
|
||||
# e.g. {"forge": 1.0, "ppo": 0.5} means keep all Forge data but
|
||||
# randomly sample 50% of PPO data to avoid overwhelming the baseline.
|
||||
source_weights: Dict[str, float] = field(
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.0}
|
||||
default_factory=lambda: {"forge": 1.0, "ppo": 0.5}
|
||||
)
|
||||
|
||||
# Minimum reward threshold for PPO data — only include decisions
|
||||
@@ -209,7 +209,7 @@ def build_dataset(
|
||||
Dict with keys: states, labels, game_ids, outcomes, playstyles, sources
|
||||
"""
|
||||
if source_weights is None:
|
||||
source_weights = {"forge": 1.0, "ppo": 0.0}
|
||||
source_weights = {"forge": 1.0, "ppo": 0.5}
|
||||
|
||||
# Load card embeddings
|
||||
card_index = CardEmbeddingIndex(embeddings_dir)
|
||||
@@ -502,8 +502,8 @@ def main():
|
||||
help="Sampling weight for Forge sim data (default: 1.0)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--ppo-weight", type=float, default=0.0,
|
||||
help="Sampling weight for PPO self-play data (default: 0.0, Forge-only)",
|
||||
"--ppo-weight", type=float, default=0.5,
|
||||
help="Sampling weight for PPO self-play data (default: 0.5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--min-reward", type=float, default=0.0,
|
||||
|
||||
@@ -276,13 +276,6 @@ class StateEncoder:
|
||||
"""
|
||||
Encode zone contents as mean-pooled card embeddings.
|
||||
Shape: (4 zones × 2 players × 768) = (6144,)
|
||||
|
||||
Zone embedding boundary: each zone slot is exactly ``zone_pool_dim``
|
||||
(768) floats. Empty zones produce a zero vector of that size. This
|
||||
fixed-size contract is relied upon by every downstream consumer
|
||||
(PolicyNetwork, PPOTrainer, dataset builder). See
|
||||
``StateDimensions`` in ``ml/config/scope.py`` for the canonical
|
||||
definition.
|
||||
"""
|
||||
zone_keys = ["hand", "battlefield", "graveyard", "command_zone"]
|
||||
vectors = []
|
||||
@@ -290,8 +283,6 @@ class StateEncoder:
|
||||
for p in players[:2]:
|
||||
for zone_key in zone_keys:
|
||||
cards = p.get(zone_key, [])
|
||||
# mean_pool_zone returns a zero vector when cards is empty,
|
||||
# preserving the fixed 768-dim slot size.
|
||||
pooled = self.card_index.mean_pool_zone(cards)
|
||||
vectors.append(pooled)
|
||||
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Online Learning Store (SQLite WAL)
|
||||
═════════════════════════════════════════════════════
|
||||
|
||||
Single-machine backing store for online learning data collected during
|
||||
policy inference (``/api/ml/predict`` and ``/api/policy/decide``).
|
||||
|
||||
Each prediction request can optionally log the state snapshot and the
|
||||
chosen action so that future training runs can incorporate online
|
||||
decision data alongside Forge and PPO batches.
|
||||
|
||||
Design decisions (Phase 5 architecture follow-up):
|
||||
- SQLite in WAL mode for concurrent readers + single writer, which is
|
||||
the right fit for a single-machine baseline (one API server process).
|
||||
- Separate DB file (``online_learning.db``) to avoid contention with
|
||||
the collection database.
|
||||
- Append-only writes; reads are batched by the dataset builder.
|
||||
- Thread-safe via the Python sqlite3 module's ``check_same_thread=False``
|
||||
and WAL's built-in reader/writer concurrency.
|
||||
|
||||
Usage::
|
||||
|
||||
store = OnlineLearningStore() # uses default path
|
||||
store.init_db()
|
||||
|
||||
# At prediction time:
|
||||
store.record_decision(snapshot_json, action_index, confidence, playstyle)
|
||||
|
||||
# At training time:
|
||||
decisions = store.fetch_decisions(since_rowid=last_seen, limit=10000)
|
||||
store.mark_exported(max_rowid)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("ml.serving.online_store")
|
||||
|
||||
_DEFAULT_DB_PATH = str(
|
||||
Path(__file__).parent.parent.parent / "online_learning.db"
|
||||
)
|
||||
|
||||
|
||||
class OnlineLearningStore:
|
||||
"""SQLite WAL-backed store for online policy decision collection."""
|
||||
|
||||
def __init__(self, db_path: str = None):
|
||||
self.db_path = db_path or _DEFAULT_DB_PATH
|
||||
self._local = threading.local()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Connection management
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Return a thread-local connection with WAL mode enabled."""
|
||||
if not hasattr(self._local, "conn") or self._local.conn is None:
|
||||
conn = sqlite3.connect(self.db_path, check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
# Enable WAL for concurrent read/write on single machine.
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA synchronous=NORMAL")
|
||||
self._local.conn = conn
|
||||
return self._local.conn
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the thread-local connection."""
|
||||
conn = getattr(self._local, "conn", None)
|
||||
if conn is not None:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._local.conn = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schema
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def init_db(self) -> None:
|
||||
"""Create the decisions table if it doesn't exist."""
|
||||
conn = self._get_conn()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS online_decisions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snapshot TEXT NOT NULL,
|
||||
action_idx INTEGER NOT NULL,
|
||||
confidence REAL NOT NULL DEFAULT 0.0,
|
||||
playstyle TEXT NOT NULL DEFAULT 'midrange',
|
||||
temperature REAL NOT NULL DEFAULT 1.0,
|
||||
greedy INTEGER NOT NULL DEFAULT 0,
|
||||
exported INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_od_exported
|
||||
ON online_decisions(exported);
|
||||
CREATE INDEX IF NOT EXISTS idx_od_created
|
||||
ON online_decisions(created_at);
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info("Online learning store ready: %s (WAL mode)", self.db_path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Write path (called during prediction)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_decision(
|
||||
self,
|
||||
snapshot: Dict,
|
||||
action_idx: int,
|
||||
confidence: float,
|
||||
playstyle: str = "midrange",
|
||||
temperature: float = 1.0,
|
||||
greedy: bool = False,
|
||||
) -> int:
|
||||
"""Append a policy decision for future training.
|
||||
|
||||
Returns the row id of the inserted record.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT INTO online_decisions
|
||||
(snapshot, action_idx, confidence, playstyle, temperature, greedy)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
json.dumps(snapshot, separators=(",", ":")),
|
||||
action_idx,
|
||||
confidence,
|
||||
playstyle,
|
||||
temperature,
|
||||
1 if greedy else 0,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Read path (called by dataset builder / training pipeline)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def fetch_decisions(
|
||||
self,
|
||||
since_rowid: int = 0,
|
||||
limit: int = 50000,
|
||||
only_unexported: bool = True,
|
||||
) -> List[Dict]:
|
||||
"""Fetch collected decisions for training.
|
||||
|
||||
Args:
|
||||
since_rowid: Only return rows with id > this value.
|
||||
limit: Max rows to return.
|
||||
only_unexported: If True, skip rows already marked exported.
|
||||
|
||||
Returns:
|
||||
List of dicts with keys: id, snapshot (parsed), action_idx,
|
||||
confidence, playstyle, temperature, greedy, created_at.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
clauses = ["id > ?"]
|
||||
params: list = [since_rowid]
|
||||
if only_unexported:
|
||||
clauses.append("exported = 0")
|
||||
where = " AND ".join(clauses)
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM online_decisions WHERE {where} ORDER BY id LIMIT ?",
|
||||
(*params, limit),
|
||||
).fetchall()
|
||||
results = []
|
||||
for row in rows:
|
||||
d = dict(row)
|
||||
try:
|
||||
d["snapshot"] = json.loads(d["snapshot"])
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
results.append(d)
|
||||
return results
|
||||
|
||||
def mark_exported(self, up_to_rowid: int) -> int:
|
||||
"""Mark decisions as exported so they aren't fetched again.
|
||||
|
||||
Returns the number of rows updated.
|
||||
"""
|
||||
conn = self._get_conn()
|
||||
cur = conn.execute(
|
||||
"UPDATE online_decisions SET exported = 1 WHERE id <= ? AND exported = 0",
|
||||
(up_to_rowid,),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def count(self, only_unexported: bool = False) -> int:
|
||||
"""Return the total number of stored decisions."""
|
||||
conn = self._get_conn()
|
||||
if only_unexported:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM online_decisions WHERE exported = 0"
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) FROM online_decisions"
|
||||
).fetchone()
|
||||
return row[0]
|
||||
@@ -1,40 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab - Training Subpackage
|
||||
==========================================
|
||||
Provides RL training infrastructure including PPO, rollout buffers,
|
||||
Forge episode generation, self-play, and distillation.
|
||||
"""
|
||||
|
||||
from ml.training.rollout_buffer import RolloutBuffer
|
||||
from ml.training.reward import compute_intermediate_reward, compute_terminal_reward, RewardConfig
|
||||
from ml.training.decision_exporter import DecisionExporter
|
||||
from ml.training.forge_episode_generator import (
|
||||
ForgeTransition,
|
||||
ForgeEpisode,
|
||||
ForgeEpisodeQueue,
|
||||
forge_producer,
|
||||
ppo_consumer,
|
||||
run_forge_pipeline,
|
||||
parse_forge_jsonl,
|
||||
snapshots_to_episode,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Rollout & Buffers
|
||||
"RolloutBuffer",
|
||||
# Rewards
|
||||
"compute_intermediate_reward",
|
||||
"compute_terminal_reward",
|
||||
"RewardConfig",
|
||||
# Decision export
|
||||
"DecisionExporter",
|
||||
# Forge episode generation (Phase 3)
|
||||
"ForgeTransition",
|
||||
"ForgeEpisode",
|
||||
"ForgeEpisodeQueue",
|
||||
"forge_producer",
|
||||
"ppo_consumer",
|
||||
"run_forge_pipeline",
|
||||
"parse_forge_jsonl",
|
||||
"snapshots_to_episode",
|
||||
]
|
||||
@@ -84,9 +84,9 @@ class DistillationConfig:
|
||||
opponent: str = "heuristic"
|
||||
playstyle: str = "midrange"
|
||||
|
||||
# --- Dataset building (Forge-only default; see MIXED_MODE_PRESETS) ---
|
||||
# --- Dataset building ---
|
||||
forge_weight: float = 1.0
|
||||
ppo_weight: float = 0.0
|
||||
ppo_weight: float = 0.5
|
||||
min_reward_threshold: float = 0.0
|
||||
|
||||
# --- Quality gate ---
|
||||
@@ -858,7 +858,7 @@ def main():
|
||||
|
||||
# Dataset
|
||||
parser.add_argument("--forge-weight", type=float, default=1.0)
|
||||
parser.add_argument("--ppo-weight", type=float, default=0.0)
|
||||
parser.add_argument("--ppo-weight", type=float, default=0.5)
|
||||
parser.add_argument("--min-reward", type=float, default=0.0)
|
||||
|
||||
# Quality gate
|
||||
|
||||
@@ -1,352 +0,0 @@
|
||||
"""
|
||||
Commander AI Lab — Forge Episode Generator (Phase 3.2)
|
||||
|
||||
Async producer-consumer pipeline: runs Forge batch games in parallel
|
||||
with PPO training. Forge produces episodes → shared queue → PPO
|
||||
trainer consumes.
|
||||
|
||||
Uses the existing watchdog (_global_watchdog_loop in forge_runner.py)
|
||||
to monitor Forge worker processes for stalls.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Deque, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
from ml.config.scope import (
|
||||
ACTION_TO_IDX,
|
||||
GAME_SCOPE,
|
||||
IDX_TO_ACTION,
|
||||
MacroAction,
|
||||
NUM_ACTIONS,
|
||||
STATE_DIMS,
|
||||
)
|
||||
from ml.training.reward import (
|
||||
RewardConfig,
|
||||
compute_intermediate_reward,
|
||||
compute_terminal_reward,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("ml.forge_episode_generator")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Episode / Trajectory data structures
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
@dataclass
|
||||
class ForgeTransition:
|
||||
"""Single (s, a, r, log_prob, value, done) tuple from a Forge game."""
|
||||
state: np.ndarray
|
||||
action: int
|
||||
reward: float
|
||||
log_prob: float
|
||||
value: float
|
||||
done: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeEpisode:
|
||||
"""Complete trajectory from a single Forge game."""
|
||||
game_id: str
|
||||
transitions: List[ForgeTransition] = field(default_factory=list)
|
||||
winner: Optional[int] = None
|
||||
total_turns: int = 0
|
||||
deck_names: List[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def length(self) -> int:
|
||||
return len(self.transitions)
|
||||
|
||||
@property
|
||||
def total_reward(self) -> float:
|
||||
return sum(t.reward for t in self.transitions)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# JSONL Parser — reads Forge ML decision snapshots
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
def parse_forge_jsonl(path: Path) -> List[Dict[str, Any]]:
|
||||
"""Parse a JSONL file of Forge ML decision snapshots.
|
||||
|
||||
Each line is a JSON object with keys like:
|
||||
game_id, turn, phase, active_seat, state, action, outcome
|
||||
"""
|
||||
snapshots = []
|
||||
with open(path, "r") as f:
|
||||
for lineno, line in enumerate(f, 1):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
snapshots.append(json.loads(line))
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning("Skipping malformed line %d in %s: %s", lineno, path, e)
|
||||
logger.info("Parsed %d snapshots from %s", len(snapshots), path)
|
||||
return snapshots
|
||||
|
||||
|
||||
def snapshots_to_episode(
|
||||
snapshots: List[Dict[str, Any]],
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> ForgeEpisode:
|
||||
"""Convert a sequence of Forge snapshots into a ForgeEpisode.
|
||||
|
||||
Maps Forge action labels → MacroAction indices and computes
|
||||
rewards from game outcomes using ml.training.reward.
|
||||
"""
|
||||
if not snapshots:
|
||||
return ForgeEpisode(game_id="empty")
|
||||
|
||||
first = snapshots[0]
|
||||
episode = ForgeEpisode(
|
||||
game_id=first.get("game_id", "unknown"),
|
||||
deck_names=first.get("deck_names", []),
|
||||
)
|
||||
|
||||
rc = reward_config or RewardConfig()
|
||||
|
||||
for i, snap in enumerate(snapshots):
|
||||
# State vector — zero-padded if not present
|
||||
state_vec = np.zeros(STATE_DIMS.total_state_dim, dtype=np.float32)
|
||||
if "state_vector" in snap:
|
||||
raw = np.array(snap["state_vector"], dtype=np.float32)
|
||||
state_vec[: min(len(raw), len(state_vec))] = raw[: len(state_vec)]
|
||||
|
||||
# Action mapping
|
||||
action_label = snap.get("action", "pass").lower().replace(" ", "_")
|
||||
try:
|
||||
macro = MacroAction(action_label)
|
||||
action_idx = ACTION_TO_IDX[macro]
|
||||
except (ValueError, KeyError):
|
||||
action_idx = ACTION_TO_IDX[MacroAction.PASS]
|
||||
|
||||
# Reward
|
||||
is_done = i == len(snapshots) - 1
|
||||
if is_done and "outcome" in snap:
|
||||
reward = compute_terminal_reward(
|
||||
won=snap["outcome"].get("won", False),
|
||||
turns=snap.get("turn", 1),
|
||||
life_remaining=snap["outcome"].get("life_remaining", 0),
|
||||
config=rc,
|
||||
)
|
||||
else:
|
||||
reward = compute_intermediate_reward(
|
||||
life_delta=snap.get("life_delta", 0),
|
||||
cards_drawn=snap.get("cards_drawn", 0),
|
||||
creatures_played=snap.get("creatures_played", 0),
|
||||
config=rc,
|
||||
)
|
||||
|
||||
episode.transitions.append(ForgeTransition(
|
||||
state=state_vec,
|
||||
action=action_idx,
|
||||
reward=reward,
|
||||
log_prob=snap.get("log_prob", 0.0),
|
||||
value=snap.get("value", 0.0),
|
||||
done=is_done,
|
||||
))
|
||||
|
||||
episode.total_turns = snapshots[-1].get("turn", len(snapshots))
|
||||
if "outcome" in snapshots[-1]:
|
||||
episode.winner = snapshots[-1]["outcome"].get("winner_seat")
|
||||
|
||||
return episode
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Async Episode Queue (producer-consumer)
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
class ForgeEpisodeQueue:
|
||||
"""Thread-safe async queue bridging Forge producers and PPO consumer.
|
||||
|
||||
Forge batch workers produce ForgeEpisodes; the PPO trainer consumes
|
||||
them as they become available. Backpressure is applied when the
|
||||
queue reaches max_size.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 256):
|
||||
self._queue: asyncio.Queue[ForgeEpisode] = asyncio.Queue(maxsize=max_size)
|
||||
self._produced = 0
|
||||
self._consumed = 0
|
||||
self._closed = False
|
||||
|
||||
async def put(self, episode: ForgeEpisode) -> None:
|
||||
await self._queue.put(episode)
|
||||
self._produced += 1
|
||||
|
||||
async def get(self, timeout: float = 30.0) -> Optional[ForgeEpisode]:
|
||||
try:
|
||||
ep = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||
self._consumed += 1
|
||||
return ep
|
||||
except asyncio.TimeoutError:
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
self._closed = True
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool:
|
||||
return self._closed and self._queue.empty()
|
||||
|
||||
@property
|
||||
def stats(self) -> Dict[str, int]:
|
||||
return {
|
||||
"produced": self._produced,
|
||||
"consumed": self._consumed,
|
||||
"pending": self._queue.qsize(),
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Forge Batch Producer
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
async def forge_producer(
|
||||
queue: ForgeEpisodeQueue,
|
||||
results_dir: str = "results",
|
||||
batch_id: str = "forge-batch",
|
||||
num_games: int = 64,
|
||||
poll_interval: float = 2.0,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> int:
|
||||
"""Producer coroutine: watches for Forge JSONL output files,
|
||||
parses them into episodes, and pushes them onto the queue.
|
||||
|
||||
Returns total number of episodes produced.
|
||||
"""
|
||||
results_path = Path(results_dir)
|
||||
pattern = f"ml-decisions-forge-{batch_id}*.jsonl"
|
||||
seen_files: set = set()
|
||||
total_produced = 0
|
||||
|
||||
logger.info("[PRODUCER] Watching %s for pattern %s", results_path, pattern)
|
||||
|
||||
while total_produced < num_games and not queue.is_closed:
|
||||
for jsonl_file in sorted(results_path.glob(pattern)):
|
||||
if jsonl_file in seen_files:
|
||||
continue
|
||||
seen_files.add(jsonl_file)
|
||||
|
||||
try:
|
||||
snapshots = parse_forge_jsonl(jsonl_file)
|
||||
if not snapshots:
|
||||
continue
|
||||
|
||||
# Group by game_id
|
||||
games: Dict[str, List] = {}
|
||||
for snap in snapshots:
|
||||
gid = snap.get("game_id", "unknown")
|
||||
games.setdefault(gid, []).append(snap)
|
||||
|
||||
for gid, game_snaps in games.items():
|
||||
episode = snapshots_to_episode(game_snaps, reward_config)
|
||||
if episode.length > 0:
|
||||
await queue.put(episode)
|
||||
total_produced += 1
|
||||
logger.debug("[PRODUCER] Queued episode %s (%d transitions)",
|
||||
gid, episode.length)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("[PRODUCER] Error processing %s: %s", jsonl_file, e)
|
||||
|
||||
await asyncio.sleep(poll_interval)
|
||||
|
||||
logger.info("[PRODUCER] Done — produced %d episodes", total_produced)
|
||||
return total_produced
|
||||
|
||||
|
||||
async def ppo_consumer(
|
||||
queue: ForgeEpisodeQueue,
|
||||
buffer_size: int = 64,
|
||||
on_batch_ready=None,
|
||||
) -> int:
|
||||
"""Consumer coroutine: pulls episodes from the queue and batches
|
||||
them for PPO training updates.
|
||||
|
||||
Args:
|
||||
queue: ForgeEpisodeQueue to consume from
|
||||
buffer_size: number of episodes per training batch
|
||||
on_batch_ready: callback(episodes) when a batch is full
|
||||
|
||||
Returns total episodes consumed.
|
||||
"""
|
||||
batch: List[ForgeEpisode] = []
|
||||
total_consumed = 0
|
||||
|
||||
while not queue.is_closed:
|
||||
episode = await queue.get(timeout=10.0)
|
||||
if episode is None:
|
||||
if queue.is_closed:
|
||||
break
|
||||
continue
|
||||
|
||||
batch.append(episode)
|
||||
total_consumed += 1
|
||||
|
||||
if len(batch) >= buffer_size:
|
||||
if on_batch_ready:
|
||||
on_batch_ready(batch)
|
||||
logger.info("[CONSUMER] Batch of %d episodes ready for PPO update", len(batch))
|
||||
batch = []
|
||||
|
||||
# Flush remaining
|
||||
if batch and on_batch_ready:
|
||||
on_batch_ready(batch)
|
||||
logger.info("[CONSUMER] Final batch of %d episodes flushed", len(batch))
|
||||
|
||||
logger.info("[CONSUMER] Done — consumed %d episodes", total_consumed)
|
||||
return total_consumed
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
# Convenience runner
|
||||
# ══════════════════════════════════════════════════════════
|
||||
|
||||
async def run_forge_pipeline(
|
||||
results_dir: str = "results",
|
||||
batch_id: str = "forge-batch",
|
||||
num_games: int = 64,
|
||||
buffer_size: int = 64,
|
||||
max_queue: int = 256,
|
||||
on_batch_ready=None,
|
||||
reward_config: Optional[RewardConfig] = None,
|
||||
) -> Dict[str, int]:
|
||||
"""Run the full Forge producer → PPO consumer pipeline.
|
||||
|
||||
The Forge Java batch must be started separately (via forge_runner.py
|
||||
or MultiThreadBatchRunner). This function handles the Python side:
|
||||
reading JSONL output and feeding episodes into the PPO trainer.
|
||||
"""
|
||||
queue = ForgeEpisodeQueue(max_size=max_queue)
|
||||
|
||||
producer_task = asyncio.create_task(
|
||||
forge_producer(queue, results_dir, batch_id, num_games,
|
||||
reward_config=reward_config)
|
||||
)
|
||||
consumer_task = asyncio.create_task(
|
||||
ppo_consumer(queue, buffer_size, on_batch_ready)
|
||||
)
|
||||
|
||||
produced = await producer_task
|
||||
queue.close()
|
||||
consumed = await consumer_task
|
||||
|
||||
stats = {
|
||||
"produced": produced,
|
||||
"consumed": consumed,
|
||||
**queue.stats,
|
||||
}
|
||||
logger.info("[PIPELINE] Complete: %s", stats)
|
||||
return stats
|
||||
@@ -17,15 +17,9 @@ that feed directly into the RolloutBuffer for PPO training.
|
||||
For real game integration (Phase 9+), the Java sim would call the
|
||||
policy server and feed back actual game states — this synthetic
|
||||
version allows training to begin immediately.
|
||||
|
||||
.. deprecated:: Phase 3 (Issue #83)
|
||||
Synthetic training mode is DEPRECATED for production training.
|
||||
Use forge_episode_generator.py with real Forge engine games.
|
||||
Retained ONLY for quick local testing and unit tests.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import warnings
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
@@ -48,12 +42,6 @@ from ml.training.decision_exporter import DecisionExporter
|
||||
|
||||
logger = logging.getLogger("ml.self_play")
|
||||
|
||||
_TRAINING_DEPRECATION_MSG = (
|
||||
"self_play.py synthetic training is deprecated for production training (Issue #83). "
|
||||
"Use forge_episode_generator.py with real Forge engine games instead. "
|
||||
"Synthetic self-play is retained for local testing and unit tests only."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlayerState:
|
||||
@@ -566,8 +554,7 @@ def collect_rollouts(
|
||||
total_wins = 0
|
||||
total_losses = 0
|
||||
total_draws = 0
|
||||
warnings.warn(_TRAINING_DEPRECATION_MSG, DeprecationWarning, stacklevel=2)
|
||||
total_steps = 0
|
||||
total_steps = 0
|
||||
total_rewards = 0.0
|
||||
|
||||
for ep in range(num_episodes):
|
||||
|
||||
@@ -13,7 +13,3 @@ requests>=2.31.0
|
||||
beautifulsoup4>=4.12.0
|
||||
ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
|
||||
# Embeddings
|
||||
sentence-transformers>=3.0.0
|
||||
torch>=2.0.0
|
||||
+1
-1
@@ -2,5 +2,5 @@
|
||||
routes package — API routers for Commander AI Lab.
|
||||
|
||||
Each module defines a FastAPI APIRouter with real endpoint implementations.
|
||||
Shared helpers live in services/, models live in models/.
|
||||
All shared helpers, models, and state live in routes.shared.
|
||||
"""
|
||||
+13
-70
@@ -33,14 +33,12 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from models.requests import CoachRequestBody, CoachChatMessage, CoachChatRequest, CoachApplyRequest, CoachGoalsRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import _compute_deck_analysis
|
||||
from services.logging import log_coach, log_deckgen
|
||||
import httpx
|
||||
from coach.config import COACH_PROVIDER, PPLX_MODEL
|
||||
|
||||
LLM_TIMEOUT = 120 # seconds
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
log_coach, log_deckgen,
|
||||
_get_db_conn,
|
||||
_compute_deck_analysis,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["coach"])
|
||||
|
||||
@@ -89,7 +87,7 @@ def init_coach_service():
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on localhost:11434)")
|
||||
log_coach.warning(f" Coach LLM: Not connected (start Ollama on 192.168.0.240:11434)")
|
||||
|
||||
# Initialize V3 Deck Generator (Ollama-based)
|
||||
_deck_gen_v3_error = None
|
||||
@@ -421,68 +419,13 @@ async def coach_chat(body: CoachChatRequest):
|
||||
# Build messages array for LLM
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
for msg in body.messages:
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
# ── Perplexity provider path ──────────────────────────────
|
||||
if COACH_PROVIDER == 'perplexity':
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var.')
|
||||
pplx_payload = {
|
||||
'model': PPLX_MODEL,
|
||||
'messages': messages,
|
||||
'max_tokens': 4096,
|
||||
'temperature': 0.7,
|
||||
}
|
||||
pplx_headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
}
|
||||
if body.stream:
|
||||
async def pplx_stream():
|
||||
pplx_payload['stream'] = True
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
async with client.stream('POST', 'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers) 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(pplx_stream(), media_type='text/event-stream')
|
||||
else:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
resp = await client.post(
|
||||
'https://api.perplexity.ai/chat/completions',
|
||||
json=pplx_payload, headers=pplx_headers,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
raw = resp.json()
|
||||
content = raw.get('choices', [{}])[0].get('message', {}).get('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 httpx.HTTPStatusError as e:
|
||||
raise HTTPException(502, f'Perplexity API returned {e.response.status_code}')
|
||||
except httpx.RequestError as e:
|
||||
raise HTTPException(502, f'Perplexity API call failed: {e}')
|
||||
|
||||
# Call LLM with multi-turn messages
|
||||
messages.append({"role": msg.get("role", "user"), "content": msg.get("content", "")})
|
||||
|
||||
# Call LLM with multi-turn messages
|
||||
try:
|
||||
import json
|
||||
from urllib.request import urlopen, Request as UrlRequest
|
||||
|
||||
from coach.config import LLM_URL, LLM_TIMEOUT
|
||||
|
||||
model_name = _coach_llm._resolve_model()
|
||||
llm_body = {
|
||||
@@ -690,7 +633,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
|
||||
# Get image URL and price
|
||||
ce = conn.execute(
|
||||
"SELECT scryfall_id, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
"SELECT image_url, tcg_price FROM collection_entries WHERE LOWER(name) = LOWER(?) LIMIT 1",
|
||||
(m.name,)
|
||||
).fetchone()
|
||||
|
||||
@@ -702,7 +645,7 @@ async def coach_cards_like(card: str, colors: str = None, top_n: int = 10):
|
||||
"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,
|
||||
"image_url": ce["image_url"] if ce else None,
|
||||
"tcg_price": ce["tcg_price"] if ce else None,
|
||||
})
|
||||
|
||||
|
||||
+24
-129
@@ -25,123 +25,35 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import (
|
||||
_get_db_conn, _row_to_dict, _add_image_url, _build_collection_filters,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS,
|
||||
)
|
||||
from services.deck_service import _classify_card_type
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _parse_csv_content, _auto_infer_mapping,
|
||||
)
|
||||
from services.logging import log_collect, log_cache
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_scryfall_cache, _scryfall_rate_limit,
|
||||
_enrich_from_scryfall, _fetch_scryfall_api,
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
_get_db_conn,
|
||||
_row_to_dict,
|
||||
_add_image_url,
|
||||
_build_collection_filters,
|
||||
_detect_card_roles,
|
||||
_classify_card_type,
|
||||
_enrich_from_scryfall,
|
||||
_fetch_scryfall_api,
|
||||
_parse_finish,
|
||||
_parse_text_line,
|
||||
_parse_csv_content,
|
||||
_auto_infer_mapping,
|
||||
_scryfall_cache,
|
||||
_scryfall_rate_limit,
|
||||
_snake_to_camel,
|
||||
VALID_SORT_FIELDS,
|
||||
_JSON_FIELDS,
|
||||
SCRYFALL_CACHE_DB_PATH,
|
||||
SCRYFALL_CACHE_TTL_SECONDS,
|
||||
_API_HEADERS,
|
||||
log_collect,
|
||||
log_cache,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["collection"])
|
||||
|
||||
|
||||
@router.get("/api/collection/stats")
|
||||
async def collection_stats():
|
||||
"""Aggregate collection statistics for the dashboard."""
|
||||
conn = _get_db_conn()
|
||||
stats = {}
|
||||
|
||||
# Total cards and unique cards
|
||||
row = conn.execute(
|
||||
"SELECT COUNT(*) as total_rows, COALESCE(SUM(quantity),0) as total_cards, "
|
||||
"COALESCE(SUM(tcg_price * quantity),0) as total_value "
|
||||
"FROM collection_entries"
|
||||
).fetchone()
|
||||
stats["totalUniqueCards"] = row["total_rows"]
|
||||
stats["totalCards"] = row["total_cards"]
|
||||
stats["totalValue"] = round(row["total_value"], 2)
|
||||
|
||||
# CMC distribution (0-7+)
|
||||
cmc_rows = conn.execute(
|
||||
"SELECT CASE WHEN cmc >= 7 THEN 7 ELSE CAST(cmc AS INTEGER) END as cmc_bucket, "
|
||||
"SUM(quantity) as count FROM collection_entries GROUP BY cmc_bucket ORDER BY cmc_bucket"
|
||||
).fetchall()
|
||||
stats["cmcCurve"] = {str(r["cmc_bucket"]): r["count"] for r in cmc_rows}
|
||||
|
||||
# Color identity distribution
|
||||
color_rows = conn.execute(
|
||||
"SELECT color_identity, SUM(quantity) as count FROM collection_entries GROUP BY color_identity"
|
||||
).fetchall()
|
||||
color_counts = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
for r in color_rows:
|
||||
ci = r["color_identity"] or "[]"
|
||||
try:
|
||||
colors = json.loads(ci) if isinstance(ci, str) else ci
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
colors = []
|
||||
qty = r["count"] or 0
|
||||
if not colors:
|
||||
color_counts["C"] += qty
|
||||
else:
|
||||
for c in colors:
|
||||
if c in color_counts:
|
||||
color_counts[c] += qty
|
||||
stats["colorDistribution"] = color_counts
|
||||
|
||||
# Type distribution
|
||||
type_rows = conn.execute(
|
||||
"SELECT type_line, SUM(quantity) as count FROM collection_entries GROUP BY type_line"
|
||||
).fetchall()
|
||||
type_counts = {}
|
||||
for r in type_rows:
|
||||
tl = r["type_line"] or ""
|
||||
qty = r["count"] or 0
|
||||
for t in ["Creature", "Instant", "Sorcery", "Artifact", "Enchantment", "Planeswalker", "Land"]:
|
||||
if t in tl:
|
||||
type_counts[t] = type_counts.get(t, 0) + qty
|
||||
stats["typeDistribution"] = type_counts
|
||||
|
||||
# Category/role distribution
|
||||
cat_rows = conn.execute(
|
||||
"SELECT category, SUM(quantity) as count FROM collection_entries "
|
||||
"WHERE category != '' AND category != '[]' GROUP BY category"
|
||||
).fetchall()
|
||||
cat_counts = {}
|
||||
for r in cat_rows:
|
||||
raw = r["category"] or "[]"
|
||||
qty = r["count"] or 0
|
||||
try:
|
||||
cats = json.loads(raw) if isinstance(raw, str) else raw
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
cats = []
|
||||
if isinstance(cats, list):
|
||||
for c in cats:
|
||||
cat_counts[c] = cat_counts.get(c, 0) + qty
|
||||
stats["categoryDistribution"] = cat_counts
|
||||
|
||||
# Rarity distribution
|
||||
rarity_rows = conn.execute(
|
||||
"SELECT LOWER(rarity) as rarity, SUM(quantity) as count FROM collection_entries "
|
||||
"WHERE rarity != '' GROUP BY LOWER(rarity)"
|
||||
).fetchall()
|
||||
stats["rarityDistribution"] = {r["rarity"]: r["count"] for r in rarity_rows}
|
||||
|
||||
# Top 10 most valuable cards
|
||||
top_value = conn.execute(
|
||||
"SELECT name, tcg_price, quantity FROM collection_entries "
|
||||
"WHERE tcg_price > 0 ORDER BY tcg_price DESC LIMIT 10"
|
||||
).fetchall()
|
||||
stats["topValueCards"] = [{"name": r["name"], "price": r["tcg_price"], "qty": r["quantity"]} for r in top_value]
|
||||
|
||||
# Average CMC (excluding lands)
|
||||
avg_row = conn.execute(
|
||||
"SELECT AVG(cmc) as avg_cmc FROM collection_entries WHERE type_line NOT LIKE '%Land%'"
|
||||
).fetchone()
|
||||
stats["avgCmc"] = round(avg_row["avg_cmc"] or 0, 2)
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
@router.get("/api/collection/export")
|
||||
async def export_collection(
|
||||
format: str = "INTERNAL_CSV",
|
||||
@@ -158,17 +70,6 @@ async def export_collection(
|
||||
priceMin: Optional[float] = None,
|
||||
priceMax: Optional[float] = None,
|
||||
category: Optional[str] = None,
|
||||
rarity: Optional[str] = None,
|
||||
setCode: Optional[str] = None,
|
||||
powerMin: Optional[str] = None,
|
||||
powerMax: Optional[str] = None,
|
||||
toughMin: Optional[str] = None,
|
||||
toughMax: Optional[str] = None,
|
||||
keyword: Optional[str] = None,
|
||||
edhrecMin: Optional[int] = None,
|
||||
edhrecMax: Optional[int] = None,
|
||||
qtyMin: Optional[int] = None,
|
||||
qtyMax: Optional[int] = None,
|
||||
):
|
||||
"""Export collection in various formats with optional filters."""
|
||||
where_str, params = _build_collection_filters(
|
||||
@@ -176,12 +77,6 @@ async def export_collection(
|
||||
isBasic=isBasic, isGameChanger=isGameChanger, highSalt=highSalt,
|
||||
finish=finish, cmcMin=cmcMin, cmcMax=cmcMax,
|
||||
priceMin=priceMin, priceMax=priceMax, category=category,
|
||||
rarity=rarity, setCode=setCode,
|
||||
powerMin=powerMin, powerMax=powerMax,
|
||||
toughMin=toughMin, toughMax=toughMax,
|
||||
keyword=keyword,
|
||||
edhrecMin=edhrecMin, edhrecMax=edhrecMax,
|
||||
qtyMin=qtyMin, qtyMax=qtyMax,
|
||||
)
|
||||
|
||||
sql = f"SELECT * FROM collection_entries {where_str} ORDER BY name ASC"
|
||||
|
||||
+28
-18
@@ -36,25 +36,35 @@ from urllib.parse import quote
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import CFG
|
||||
from models.requests import (
|
||||
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
|
||||
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
|
||||
)
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict, _add_image_url
|
||||
from services.deck_service import (
|
||||
_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_classify_card_type, _to_edhrec_slug, _TYPE_TARGETS,
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
_get_db_conn,
|
||||
_row_to_dict,
|
||||
_add_image_url,
|
||||
_get_deck_or_404,
|
||||
_compute_deck_analysis,
|
||||
_check_ratio_limit,
|
||||
_classify_card_type,
|
||||
_detect_card_roles,
|
||||
_enrich_from_scryfall,
|
||||
_fetch_scryfall_api,
|
||||
_scryfall_rate_limit,
|
||||
_to_edhrec_slug,
|
||||
_edhrec_cache_get,
|
||||
_edhrec_cache_set,
|
||||
_fetch_edhrec_average,
|
||||
_save_profile_to_dck,
|
||||
)
|
||||
from services.import_helpers import (
|
||||
_fetch_edhrec_average, _parse_text_decklist,
|
||||
_edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
from services.logging import log_collect, log_deckgen
|
||||
from services.scryfall import (
|
||||
_API_HEADERS, _enrich_from_scryfall, _fetch_scryfall_api, _scryfall_rate_limit,
|
||||
_parse_text_decklist,
|
||||
_API_HEADERS,
|
||||
_TYPE_TARGETS,
|
||||
CreateDeckRequest,
|
||||
UpdateDeckRequest,
|
||||
AddDeckCardRequest,
|
||||
PatchDeckCardRequest,
|
||||
BulkAddRequest,
|
||||
BulkAddRecommendedRequest,
|
||||
log_collect,
|
||||
log_deckgen,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["deckbuilder"])
|
||||
|
||||
+58
-155
@@ -36,57 +36,18 @@ from fastapi.responses import JSONResponse, StreamingResponse
|
||||
from models.requests import DeckResearchRequest, DeckGenerateAIRequest
|
||||
|
||||
import routes.coach as _coach # for _deck_gen_v3 / _deck_gen_v3_error module-level access
|
||||
from models.state import CFG
|
||||
from models.requests import (
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
log_deckgen, log_pplx,
|
||||
_get_db_conn,
|
||||
_scryfall_rate_limit, _API_HEADERS,
|
||||
DeckGenerationRequest, DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
DeckGenerationSourceConfig,
|
||||
GeneratedDeckCard, DeckGenerationSourceConfig,
|
||||
)
|
||||
from models.responses import GeneratedDeckCard
|
||||
from services.database import _get_db_conn
|
||||
from services.logging import log_deckgen, log_pplx
|
||||
from services.scryfall import _scryfall_rate_limit, _API_HEADERS
|
||||
from services.deck_service import _write_dck_file, _build_dck_lines
|
||||
|
||||
router = APIRouter(tags=["deckgen"])
|
||||
|
||||
# ── V3 Result Cache (avoids re-generating on export) ─────────────
|
||||
import hashlib
|
||||
_v3_result_cache: dict[str, tuple[float, dict]] = {} # key -> (timestamp, result)
|
||||
_V3_CACHE_TTL = 600 # 10 minutes
|
||||
|
||||
def _v3_cache_key(req) -> str:
|
||||
"""Deterministic cache key from a DeckGenV3Request."""
|
||||
blob = json.dumps({
|
||||
'commander': req.commander_name.strip().lower(),
|
||||
'strategy': req.strategy or '',
|
||||
'bracket': req.target_bracket,
|
||||
'budget': req.budget_usd,
|
||||
'budget_mode': req.budget_mode,
|
||||
'omit': sorted(req.omit_cards or []),
|
||||
'collection': req.use_collection,
|
||||
'model': req.model or '',
|
||||
}, sort_keys=True)
|
||||
return hashlib.sha256(blob.encode()).hexdigest()[:16]
|
||||
|
||||
def _v3_cache_get(key: str) -> dict | None:
|
||||
"""Return cached result if within TTL, else None."""
|
||||
entry = _v3_result_cache.get(key)
|
||||
if entry and (time.time() - entry[0]) < _V3_CACHE_TTL:
|
||||
return entry[1]
|
||||
if entry:
|
||||
del _v3_result_cache[key]
|
||||
return None
|
||||
|
||||
def _v3_cache_put(key: str, result: dict):
|
||||
"""Store result in cache with current timestamp."""
|
||||
# Evict stale entries if cache grows large
|
||||
if len(_v3_result_cache) > 50:
|
||||
cutoff = time.time() - _V3_CACHE_TTL
|
||||
stale = [k for k, (ts, _) in _v3_result_cache.items() if ts < cutoff]
|
||||
for k in stale:
|
||||
del _v3_result_cache[k]
|
||||
_v3_result_cache[key] = (time.time(), result)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Auto Deck Generator
|
||||
@@ -103,7 +64,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
||||
# Try scryfall_id first
|
||||
if req.commander_scryfall_id:
|
||||
row = conn.execute(
|
||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE scryfall_id = ? LIMIT 1",
|
||||
(req.commander_scryfall_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
@@ -119,7 +80,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
||||
"color_identity": ci,
|
||||
"type_line": row["type_line"],
|
||||
"mana_cost": row["mana_cost"] or "",
|
||||
"oracle_text": row["oracle_text"] or "",
|
||||
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
||||
}
|
||||
# Fallback to Scryfall API
|
||||
@@ -137,7 +97,7 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
||||
if req.commander_name:
|
||||
name_lower = req.commander_name.strip().lower()
|
||||
row = conn.execute(
|
||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id, oracle_text FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
|
||||
"SELECT name, type_line, color_identity, mana_cost, scryfall_id FROM collection_entries WHERE LOWER(name) = ? LIMIT 1",
|
||||
(name_lower,)
|
||||
).fetchone()
|
||||
if row:
|
||||
@@ -153,7 +113,6 @@ def _resolve_commander(req: DeckGenerationRequest) -> dict:
|
||||
"color_identity": ci,
|
||||
"type_line": row["type_line"],
|
||||
"mana_cost": row["mana_cost"] or "",
|
||||
"oracle_text": row["oracle_text"] or "",
|
||||
"image_url": f"https://api.scryfall.com/cards/{row['scryfall_id']}?format=image&version=normal",
|
||||
}
|
||||
|
||||
@@ -185,7 +144,6 @@ def _scryfall_to_commander(data: dict) -> dict:
|
||||
"color_identity": ci,
|
||||
"type_line": data.get("type_line", ""),
|
||||
"mana_cost": data.get("mana_cost", ""),
|
||||
"oracle_text": data.get("oracle_text", ""),
|
||||
"image_url": image_uris.get("normal", ""),
|
||||
}
|
||||
|
||||
@@ -236,31 +194,6 @@ def _get_collection_for_colors(color_identity: list) -> list:
|
||||
return valid
|
||||
|
||||
|
||||
# ── Oracle-text keyword extraction for scoring ────────────────────
|
||||
_ORACLE_STOP_WORDS = frozenset({
|
||||
'a', 'an', 'the', 'of', 'to', 'and', 'or', 'is', 'it', 'its',
|
||||
'in', 'on', 'at', 'for', 'by', 'with', 'from', 'as', 'if',
|
||||
'that', 'this', 'you', 'your', 'up', 'all', 'each', 'may',
|
||||
'can', 'one', 'two', 'three', 'four', 'five', 'card', 'cards',
|
||||
'target', 'player', 'creature', 'creatures', 'spell', 'spells',
|
||||
'permanent', 'permanents', 'turn', 'end', 'step', 'phase',
|
||||
'mana', 'color', 'pay', 'cost', 'tap', 'untap', 'put', 'get',
|
||||
'has', 'have', 'are', 'be', 'been', 'was', 'were', 'do', 'does',
|
||||
'any', 'no', 'not', 'only', 'also', 'then', 'when', 'whenever',
|
||||
'where', 'which', 'who', 'them', 'they', 'their', 'other',
|
||||
'more', 'less', 'than', 'into', 'under', 'over', 'until',
|
||||
'would', 'could', 'instead', 'about', 'number', 'equal',
|
||||
})
|
||||
|
||||
|
||||
def _extract_oracle_keywords(oracle_text: str) -> set[str]:
|
||||
"""Extract meaningful keywords from oracle text, filtering stop words."""
|
||||
if not oracle_text:
|
||||
return set()
|
||||
cleaned = re.sub(r'\([^)]*\)', '', oracle_text)
|
||||
tokens = re.findall(r'[a-z]{3,}', cleaned.lower())
|
||||
return {t for t in tokens if t not in _ORACLE_STOP_WORDS}
|
||||
|
||||
def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
"""
|
||||
Core deck generation algorithm.
|
||||
@@ -279,8 +212,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
|
||||
color_identity = req.color_identity or commander.get("color_identity", [])
|
||||
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
|
||||
commander_keywords = _extract_oracle_keywords(commander.get("oracle_text", ""))
|
||||
log_deckgen.info(f" Commander keywords ({len(commander_keywords)}): {sorted(commander_keywords)[:15]}")
|
||||
|
||||
# 2. Load collection
|
||||
collection = _get_collection_for_colors(color_identity)
|
||||
@@ -347,7 +278,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
)
|
||||
candidate_map[key] = {
|
||||
"scryfall_id": card.get("scryfall_id", ""),
|
||||
"oracle_text": card.get("oracle_text", ""),
|
||||
"name": name,
|
||||
"type_line": type_line,
|
||||
"mana_cost": card.get("mana_cost", ""),
|
||||
@@ -440,17 +370,6 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
score += 5
|
||||
elif cmc <= 4:
|
||||
score += 3
|
||||
# Oracle-text keyword overlap with commander
|
||||
card_oracle = card.get("oracle_text") or ""
|
||||
if commander_keywords and card_oracle:
|
||||
card_kw = _extract_oracle_keywords(card_oracle)
|
||||
overlap = len(commander_keywords & card_kw)
|
||||
if overlap >= 4:
|
||||
score += 12
|
||||
elif overlap >= 2:
|
||||
score += 7
|
||||
elif overlap >= 1:
|
||||
score += 3
|
||||
card["_score"] = score
|
||||
cards.sort(key=lambda x: x["_score"], reverse=True)
|
||||
|
||||
@@ -852,7 +771,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
|
||||
Uses httpx.AsyncClient so the uvicorn event loop is never stalled.
|
||||
Returns the assistant message content.
|
||||
"""
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL, PPLX_MODEL
|
||||
from coach.config import DECK_GEN_PROVIDER, DECK_GEN_BASE_URL, DECK_GEN_MODEL
|
||||
|
||||
if DECK_GEN_PROVIDER == 'local':
|
||||
base_url = DECK_GEN_BASE_URL
|
||||
@@ -862,7 +781,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
|
||||
if not CFG.pplx_api_key:
|
||||
raise HTTPException(400, 'Perplexity API key not configured. Set PPLX_API_KEY env var or --pplx-key.')
|
||||
base_url = 'https://api.perplexity.ai'
|
||||
model = PPLX_MODEL
|
||||
model = 'sonar'
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {CFG.pplx_api_key}',
|
||||
@@ -879,7 +798,7 @@ async def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperatu
|
||||
f'{base_url}/chat/completions',
|
||||
json=payload,
|
||||
headers=headers,
|
||||
)
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
@@ -1028,7 +947,7 @@ def _fill_basic_lands(cards: list[dict], color_identity: list[str] | None = None
|
||||
return non_basics
|
||||
|
||||
|
||||
# ── Research Endpoint ───────────────────────────────────────────────────
|
||||
# ── Research Endpoint ─────────────────────────────────────────
|
||||
|
||||
@router.post('/api/deck-research')
|
||||
async def deck_research(req: DeckResearchRequest):
|
||||
@@ -1183,7 +1102,7 @@ Analyze EVERYTHING: the deck's identity, strategy, archetype, bracket level (1-4
|
||||
return analysis
|
||||
|
||||
|
||||
# ── Generate Endpoint ───────────────────────────────────────────────
|
||||
# ── Generate Endpoint ─────────────────────────────────────────
|
||||
|
||||
@router.post('/api/deck-generate')
|
||||
async def deck_generate_ai(req: DeckGenerateAIRequest):
|
||||
@@ -1313,7 +1232,7 @@ async def deck_gen_v3_status():
|
||||
'initialized': _coach._deck_gen_v3 is not None,
|
||||
'pplx_configured': bool(CFG.pplx_api_key),
|
||||
'model': getattr(_coach._deck_gen_v3, 'model', getattr(getattr(_coach._deck_gen_v3, 'pplx', None), 'model', 'ollama/gpt-oss:20b')) if _coach._deck_gen_v3 else None,
|
||||
'embeddings_loaded': False,
|
||||
'embeddings_loaded': False,
|
||||
'embedding_cards': 0,
|
||||
'error': _coach._deck_gen_v3_error,
|
||||
}
|
||||
@@ -1370,9 +1289,6 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
result['stats'] = DeckGeneratorV3._compute_stats(sub_result.cards)
|
||||
|
||||
# Cache result for export endpoints
|
||||
_v3_cache_put(_v3_cache_key(req), result)
|
||||
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
@@ -1490,20 +1406,17 @@ async def deck_gen_v3_export_csv(req: DeckGenV3Request):
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||
|
||||
_ck = _v3_cache_key(req)
|
||||
result = _v3_cache_get(_ck)
|
||||
if result is None:
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
_v3_cache_put(_ck, result)
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
|
||||
cards = result.get('cards', [])
|
||||
commander = result.get('commander', {})
|
||||
|
||||
@@ -1540,20 +1453,16 @@ async def deck_gen_v3_export_dck(req: DeckGenV3Request):
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||
|
||||
_ck = _v3_cache_key(req)
|
||||
result = _v3_cache_get(_ck)
|
||||
if result is None:
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
_v3_cache_put(_ck, result)
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
|
||||
cards = result.get('cards', [])
|
||||
commander = result.get('commander', {})
|
||||
@@ -1579,20 +1488,16 @@ async def deck_gen_v3_export_moxfield(req: DeckGenV3Request):
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||
|
||||
_ck = _v3_cache_key(req)
|
||||
result = _v3_cache_get(_ck)
|
||||
if result is None:
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
_v3_cache_put(_ck, result)
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
|
||||
cards = result.get('cards', [])
|
||||
commander = result.get('commander', {})
|
||||
@@ -1620,26 +1525,22 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
|
||||
if _coach._deck_gen_v3 is None:
|
||||
raise HTTPException(503, 'V3 Deck Generator not initialized.')
|
||||
|
||||
_ck = _v3_cache_key(req)
|
||||
result = _v3_cache_get(_ck)
|
||||
if result is None:
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
_v3_cache_put(_ck, result)
|
||||
result = _coach._deck_gen_v3.generate_deck(
|
||||
commander_name=req.commander_name.strip(),
|
||||
strategy=req.strategy,
|
||||
target_bracket=req.target_bracket,
|
||||
budget_usd=req.budget_usd,
|
||||
budget_mode=req.budget_mode,
|
||||
omit_cards=req.omit_cards,
|
||||
use_collection=req.use_collection,
|
||||
model=req.model,
|
||||
)
|
||||
|
||||
cards = result.get('cards', [])
|
||||
shopping = []
|
||||
total = 0.0
|
||||
for card in cards:
|
||||
if not card.get('from_collection', False) and card.get('status') != 'substituted':
|
||||
if not card.get('owned', True) and card.get('status') != 'substituted':
|
||||
price = card.get('estimated_price_usd', 0)
|
||||
shopping.append({
|
||||
'name': card.get('name', ''),
|
||||
@@ -1656,3 +1557,5 @@ async def deck_gen_v3_export_shopping(req: DeckGenV3Request):
|
||||
'total_missing': len(shopping),
|
||||
'estimated_cost_usd': round(total, 2),
|
||||
}
|
||||
|
||||
|
||||
+16
-28
@@ -28,10 +28,12 @@ from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import CFG
|
||||
from services.database import _get_db_conn
|
||||
from services.forge_runner import _get_deepseek_brain
|
||||
from services.logging import log_sim, _ml_logging_enabled
|
||||
from routes.shared import (
|
||||
CFG, log_sim,
|
||||
_get_db_conn,
|
||||
_get_deepseek_brain,
|
||||
_ml_logging_enabled,
|
||||
)
|
||||
from commander_ai_lab.sim.validator_brain import ValidatorBrain, ValidatorConfig
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,9 +56,9 @@ def _get_validator_brain() -> ValidatorBrain | None:
|
||||
if _validator_brain is None:
|
||||
cfg = ValidatorConfig(
|
||||
api_base=os.environ.get("VALIDATOR_API_BASE", "http://localhost:11434"),
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:8b"),
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
|
||||
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "120.0")),
|
||||
)
|
||||
_validator_brain = ValidatorBrain(cfg)
|
||||
return _validator_brain
|
||||
@@ -134,7 +136,7 @@ def _run_game_loop(engine, deck_a, deck_b, deck_name, opponent_name,
|
||||
name_b=opponent_name, **kw)
|
||||
game_data = result.to_dict()
|
||||
game_data['gameNumber'] = i + 1
|
||||
# Phase 8: Surface player_explanation from validation in turn log
|
||||
# Phase 8: Surface player_explanation from validation in turn log
|
||||
for turn_entry in game_data.get('log', []):
|
||||
v = turn_entry.get('validation')
|
||||
if isinstance(v, dict) and v.get('player_explanation'):
|
||||
@@ -208,7 +210,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
_sim_runs[sim_id]['status'] = 'complete'
|
||||
_sim_runs[sim_id]['result'] = {'summary': summary, 'games': game_results}
|
||||
|
||||
# --- Persist ml-decision-*.json for coach / training dashboard ---
|
||||
# --- Persist batch-*.json for coach / training dashboard ---
|
||||
try:
|
||||
deck_name = summary.get('deckName', 'Unknown')
|
||||
opponent_name = summary.get('opponentName', 'Training Deck')
|
||||
@@ -235,7 +237,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
}
|
||||
results_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'results')
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
batch_path = os.path.join(results_dir, f'ml-decision-sim-{sim_id}.json')
|
||||
batch_path = os.path.join(results_dir, f'batch-sim-{sim_id}.json')
|
||||
with open(batch_path, 'w') as f:
|
||||
json.dump(batch_data, f, indent=2, default=str)
|
||||
logger.info(f'Saved batch results to {batch_path}')
|
||||
@@ -255,8 +257,6 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
logger.error(f'Failed to persist sim results: {e}')
|
||||
|
||||
# --- Flush ML decisions JSONL for training pipeline ---
|
||||
# NOTE: DeepSeek thread flushes manually before calling _finish_sim(engine=None)
|
||||
# to avoid a double-flush that would produce an empty .jsonl file.
|
||||
if engine is not None and hasattr(engine, 'flush_ml_decisions'):
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions:
|
||||
@@ -266,8 +266,7 @@ def _finish_sim(sim_id, summary, game_results, engine=None):
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
logger.info(f'Wrote {len(ml_decisions)} ML decisions to {ml_path}')
|
||||
|
||||
|
||||
|
||||
def _fail_sim(sim_id, error):
|
||||
"""Mark a sim run as errored."""
|
||||
with _sim_lock:
|
||||
@@ -363,32 +362,21 @@ def _run_sim_thread_deepseek(sim_id: str, card_data: list[dict],
|
||||
game_id_prefix=f'ds-sim-{sim_id[:8]}',
|
||||
archetype='midrange')
|
||||
|
||||
# Flush ML decisions once here — pass engine=None to _finish_sim
|
||||
# to prevent a second flush that would produce an empty .jsonl file.
|
||||
# Write ML decision JSONL for training pipeline
|
||||
ml_decisions = engine.flush_ml_decisions()
|
||||
if ml_decisions and brain and brain._connected:
|
||||
if ml_decisions:
|
||||
ml_path = os.path.join('results',
|
||||
f'ml-decisions-sim-{sim_id[:8]}.jsonl')
|
||||
os.makedirs('results', exist_ok=True)
|
||||
with open(ml_path, 'w', encoding='utf-8') as mf:
|
||||
for dec in ml_decisions:
|
||||
mf.write(json.dumps(dec) + '\n')
|
||||
logger.info(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {ml_path}')
|
||||
elif ml_decisions and (not brain or not brain._connected):
|
||||
logger.warning(
|
||||
f'[ML Data] Skipping {len(ml_decisions)} snapshots for sim {sim_id} — '
|
||||
f'brain not connected, all actions are heuristic fallback '
|
||||
f'(not useful for supervised training)'
|
||||
)
|
||||
else:
|
||||
logger.warning(f'[ML Data] No decision snapshots captured for sim {sim_id} (0 decisions)')
|
||||
|
||||
ds_stats = brain.get_stats() if brain else {}
|
||||
summary = _build_summary(
|
||||
stats, deck_name, 'DeepSeek AI', num_games, elapsed,
|
||||
opponentType='deepseek', deepseekStats=ds_stats)
|
||||
# engine=None: ML decisions already flushed above — do not flush again
|
||||
_finish_sim(sim_id, summary, game_results, engine=None)
|
||||
_finish_sim(sim_id, summary, game_results, engine=engine)
|
||||
except Exception as e:
|
||||
_fail_sim(sim_id, e)
|
||||
traceback.print_exc()
|
||||
@@ -795,7 +783,7 @@ async def deepseek_logs():
|
||||
'flushedPath': flushed_path,
|
||||
'logFiles': log_files[-20:],
|
||||
})
|
||||
|
||||
|
||||
|
||||
@router.get("/api/validator/status")
|
||||
async def validator_status():
|
||||
|
||||
+11
-7
@@ -15,13 +15,17 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import COMMANDER_META
|
||||
from models.requests import ImportUrlRequest, ImportTextRequest, MetaFetchRequest
|
||||
from services.import_helpers import (
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
import routes.shared as _shared
|
||||
from routes.shared import (
|
||||
ImportUrlRequest,
|
||||
ImportTextRequest,
|
||||
MetaFetchRequest,
|
||||
_import_from_url,
|
||||
_parse_text_decklist,
|
||||
_save_profile_to_dck,
|
||||
_fetch_edhrec_average,
|
||||
log,
|
||||
)
|
||||
from services.logging import log
|
||||
|
||||
router = APIRouter(tags=["import"])
|
||||
|
||||
@@ -70,7 +74,7 @@ async def import_from_text(req: ImportTextRequest):
|
||||
async def list_meta_commanders():
|
||||
"""List available commanders in the meta mapping."""
|
||||
commanders = []
|
||||
for name, entries in COMMANDER_META.items():
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
entry = entries[0] if entries else {}
|
||||
commanders.append({
|
||||
"name": name,
|
||||
@@ -86,7 +90,7 @@ async def search_meta_commanders(q: str = ""):
|
||||
"""Search commanders by partial name match."""
|
||||
query = q.lower()
|
||||
matches = []
|
||||
for name, entries in COMMANDER_META.items():
|
||||
for name, entries in _shared.COMMANDER_META.items():
|
||||
if query in name.lower():
|
||||
entry = entries[0] if entries else {}
|
||||
matches.append({
|
||||
|
||||
+19
-11
@@ -31,15 +31,23 @@ from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from models.state import CFG, active_batches, BatchState, AI_PROFILES
|
||||
from models.requests import StartRequest
|
||||
from models.responses import StartResponse, StatusResponse
|
||||
from services.database import _get_db_conn
|
||||
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
|
||||
from services.forge_runner import (
|
||||
build_java_command, run_batch_subprocess, _run_deepseek_batch_thread,
|
||||
from routes.shared import (
|
||||
CFG,
|
||||
active_batches,
|
||||
BatchState,
|
||||
StartRequest,
|
||||
StartResponse,
|
||||
StatusResponse,
|
||||
AI_PROFILES,
|
||||
build_java_command,
|
||||
run_batch_subprocess,
|
||||
_run_deepseek_batch_thread,
|
||||
parse_dck_file,
|
||||
_get_db_conn,
|
||||
_load_deck_cards_by_name,
|
||||
log,
|
||||
log_batch,
|
||||
)
|
||||
from services.logging import log, log_batch
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
|
||||
@@ -79,7 +87,7 @@ async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
active_batches[batch_id] = state
|
||||
|
||||
os.makedirs(CFG.results_dir, exist_ok=True)
|
||||
output_path = os.path.join(CFG.results_dir, f"ml-decision-{batch_id}.json")
|
||||
output_path = os.path.join(CFG.results_dir, f"batch-{batch_id}.json")
|
||||
state.result_path = output_path
|
||||
|
||||
# Auto-export deckbuilder decks to .dck if not already exported
|
||||
@@ -242,7 +250,7 @@ async def list_history():
|
||||
if not results_dir.exists():
|
||||
return {"results": []}
|
||||
results = []
|
||||
for f in sorted(results_dir.glob("ml-decision-*.json"), reverse=True):
|
||||
for f in sorted(results_dir.glob("batch-*.json"), reverse=True):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
@@ -375,7 +383,7 @@ async def get_deck_trends(deck_name: str):
|
||||
if not results_dir.exists():
|
||||
return {"deckName": deck_name, "history": []}
|
||||
history = []
|
||||
for f in sorted(results_dir.glob("ml-decision-*.json")):
|
||||
for f in sorted(results_dir.glob("batch-*.json")):
|
||||
try:
|
||||
with open(f, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
|
||||
+8
-133
@@ -32,8 +32,7 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request as FastAPIRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.logging import _ml_logging_enabled
|
||||
from routes.shared import CFG, _ml_logging_enabled
|
||||
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
|
||||
@@ -122,7 +121,7 @@ def _get_policy_service():
|
||||
@router.get("/api/ml/status")
|
||||
async def ml_status():
|
||||
"""Get ML decision logging status and available training data."""
|
||||
import services.logging as _logging_mod
|
||||
import routes.shared as _shared
|
||||
lab_root = Path(__file__).resolve().parent.parent
|
||||
results_dir = lab_root / CFG.results_dir
|
||||
ml_files = []
|
||||
@@ -141,7 +140,7 @@ async def ml_status():
|
||||
})
|
||||
total_decisions = sum(f["decisions"] for f in ml_files)
|
||||
return {
|
||||
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"training_files": ml_files,
|
||||
"total_decisions": total_decisions,
|
||||
"total_files": len(ml_files),
|
||||
@@ -151,10 +150,10 @@ async def ml_status():
|
||||
@router.post("/api/ml/toggle")
|
||||
async def ml_toggle(enable: bool = True):
|
||||
"""Enable or disable ML decision logging for future batch runs."""
|
||||
import services.logging as _logging_mod
|
||||
_logging_mod._ml_logging_enabled = enable
|
||||
import routes.shared as _shared
|
||||
_shared._ml_logging_enabled = enable
|
||||
return {
|
||||
"ml_logging_enabled": _logging_mod._ml_logging_enabled,
|
||||
"ml_logging_enabled": _shared._ml_logging_enabled,
|
||||
"message": f"ML decision logging {'enabled' if enable else 'disabled'} for future batches",
|
||||
}
|
||||
|
||||
@@ -189,95 +188,10 @@ async def ml_get_decisions(filename: str, limit: int = 100, offset: int = 0):
|
||||
}
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# Online Learning Store (SQLite WAL — Phase 5 follow-up)
|
||||
# ==============================================================
|
||||
|
||||
_online_store = None
|
||||
_online_store_init_attempted = False
|
||||
|
||||
def _get_online_store():
|
||||
"""Lazy-init the online learning store singleton."""
|
||||
global _online_store, _online_store_init_attempted
|
||||
if _online_store is None and not _online_store_init_attempted:
|
||||
_online_store_init_attempted = True
|
||||
try:
|
||||
from ml.serving.online_learning_store import OnlineLearningStore
|
||||
_online_store = OnlineLearningStore()
|
||||
_online_store.init_db()
|
||||
log_ml.info("Online learning store initialized")
|
||||
except Exception as e:
|
||||
log_ml.error("Online learning store init failed: %s", e)
|
||||
return _online_store
|
||||
|
||||
|
||||
# ==============================================================
|
||||
# ML Policy Inference Endpoints
|
||||
# ==============================================================
|
||||
|
||||
@router.post("/api/policy/decide")
|
||||
async def policy_decide(request: FastAPIRequest):
|
||||
"""Predict a macro-action and log the decision for online learning.
|
||||
|
||||
Combines prediction with automatic data collection: every decision
|
||||
is appended to the SQLite WAL-backed online learning store so
|
||||
future training runs can incorporate live gameplay data.
|
||||
"""
|
||||
svc = _get_policy_service()
|
||||
if svc is None or not svc._loaded:
|
||||
detail = "Policy model not loaded. "
|
||||
if svc:
|
||||
detail += svc._load_error or "Train a model first."
|
||||
else:
|
||||
detail += "PyTorch may not be installed."
|
||||
raise HTTPException(status_code=503, detail=detail)
|
||||
|
||||
body = await request.json()
|
||||
playstyle = body.pop("archetype", "midrange")
|
||||
temperature = body.pop("temperature", 1.0)
|
||||
greedy = body.pop("greedy", False)
|
||||
collect = body.pop("collect", True) # opt-out flag
|
||||
|
||||
result = svc.predict(
|
||||
decision_snapshot=body,
|
||||
playstyle=playstyle,
|
||||
temperature=temperature,
|
||||
greedy=greedy,
|
||||
)
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=500, detail=result["error"])
|
||||
|
||||
# Record to online learning store (fire-and-forget — don't block response)
|
||||
if collect:
|
||||
store = _get_online_store()
|
||||
if store is not None:
|
||||
try:
|
||||
store.record_decision(
|
||||
snapshot=body,
|
||||
action_idx=result["action_index"],
|
||||
confidence=result["confidence"],
|
||||
playstyle=playstyle,
|
||||
temperature=temperature,
|
||||
greedy=greedy,
|
||||
)
|
||||
except Exception as e:
|
||||
log_ml.warning("Online learning record failed: %s", e)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/policy/decide/stats")
|
||||
async def policy_decide_stats():
|
||||
"""Return counts of collected online learning decisions."""
|
||||
store = _get_online_store()
|
||||
if store is None:
|
||||
return {"total": 0, "unexported": 0, "error": "Store not initialized"}
|
||||
return {
|
||||
"total": store.count(),
|
||||
"unexported": store.count(only_unexported=True),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/ml/predict")
|
||||
async def ml_predict(request: FastAPIRequest):
|
||||
"""Predict a macro-action from a game state snapshot."""
|
||||
@@ -863,8 +777,6 @@ def _run_distillation_pipeline(
|
||||
opponent: str,
|
||||
playstyle: str,
|
||||
min_win_rate: float,
|
||||
forge_weight: float = 1.0,
|
||||
ppo_weight: float = 0.0,
|
||||
):
|
||||
"""Run the distillation loop in a background thread."""
|
||||
global _distillation_loop
|
||||
@@ -888,8 +800,6 @@ def _run_distillation_pipeline(
|
||||
opponent=opponent,
|
||||
playstyle=playstyle,
|
||||
min_ppo_win_rate=min_win_rate,
|
||||
forge_weight=forge_weight,
|
||||
ppo_weight=ppo_weight,
|
||||
results_dir=os.path.join(project_root, "results"),
|
||||
models_dir=os.path.join(project_root, "ml", "models"),
|
||||
checkpoint_dir=os.path.join(project_root, "ml", "models", "checkpoints"),
|
||||
@@ -937,21 +847,9 @@ def _run_distillation_pipeline(
|
||||
_distillation_loop = None
|
||||
|
||||
|
||||
@router.get("/api/ml/distill/presets")
|
||||
async def ml_distill_presets():
|
||||
"""List available mixed-mode weight presets for distillation."""
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
return {"presets": MIXED_MODE_PRESETS}
|
||||
|
||||
|
||||
@router.post("/api/ml/distill/start")
|
||||
async def ml_start_distillation(request: FastAPIRequest):
|
||||
"""Start the closed-loop distillation pipeline.
|
||||
|
||||
Accepts an optional ``preset`` field (e.g. ``"forge_90_10"``)
|
||||
which overrides ``forgeWeight`` / ``ppoWeight`` with a named
|
||||
configuration from ``MIXED_MODE_PRESETS``.
|
||||
"""
|
||||
"""Start the closed-loop distillation pipeline."""
|
||||
if _distillation_state.running:
|
||||
raise HTTPException(409, "Distillation loop already in progress")
|
||||
if _training_state.running:
|
||||
@@ -973,22 +871,6 @@ async def ml_start_distillation(request: FastAPIRequest):
|
||||
playstyle = body.get("playstyle", "midrange")
|
||||
min_win_rate = body.get("minWinRate", 0.30)
|
||||
|
||||
# Resolve weights: preset > explicit params > defaults (Forge-only)
|
||||
forge_weight = 1.0
|
||||
ppo_weight = 0.0
|
||||
preset_name = body.get("preset")
|
||||
if preset_name:
|
||||
from ml.config.scope import get_preset_weights
|
||||
try:
|
||||
pw = get_preset_weights(preset_name)
|
||||
forge_weight = pw["forge_weight"]
|
||||
ppo_weight = pw["ppo_weight"]
|
||||
except KeyError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
else:
|
||||
forge_weight = body.get("forgeWeight", 1.0)
|
||||
ppo_weight = body.get("ppoWeight", 0.0)
|
||||
|
||||
with _distillation_lock:
|
||||
_distillation_state.running = True
|
||||
_distillation_state.generation = 0
|
||||
@@ -1008,19 +890,12 @@ async def ml_start_distillation(request: FastAPIRequest):
|
||||
supervised_epochs, supervised_lr,
|
||||
ppo_iterations, ppo_episodes_per_iter, ppo_lr,
|
||||
opponent, playstyle, min_win_rate,
|
||||
forge_weight, ppo_weight,
|
||||
),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"max_iterations": max_iterations,
|
||||
"forge_weight": forge_weight,
|
||||
"ppo_weight": ppo_weight,
|
||||
"preset": preset_name,
|
||||
}
|
||||
return {"status": "started", "max_iterations": max_iterations}
|
||||
|
||||
|
||||
@router.get("/api/ml/distill/status")
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
"""Commander AI Lab — Policy Decision Routes (Phase 2)
|
||||
════════════════════════════════════════════════════
|
||||
|
||||
Live Forge ↔ policy server IPC endpoints for online training.
|
||||
These endpoints are purpose-built for the real-time decision loop
|
||||
where Forge's Java side calls the Python policy server at each
|
||||
game decision point.
|
||||
|
||||
Endpoints:
|
||||
POST /api/policy/decide — Accept full Forge game state, return action + log_prob + value
|
||||
POST /api/policy/collect — Accept batch of (state, action, reward) tuples
|
||||
POST /api/policy/reward — Submit end-of-game reward
|
||||
GET /api/policy/health — Readiness check for the live IPC loop
|
||||
GET /api/policy/stats — Online learning session statistics
|
||||
|
||||
Refs: Issue #83 Phase 2.2
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger("routes.policy")
|
||||
|
||||
router = APIRouter(prefix="/api/policy", tags=["policy"])
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Request / Response Models
|
||||
# ================================================================
|
||||
|
||||
class PlayerZoneState(BaseModel):
|
||||
"""Per-player zone state from Forge."""
|
||||
seat: int = 0
|
||||
name: str = ""
|
||||
life: int = 40
|
||||
poison: int = 0
|
||||
cmdr_dmg: Dict[str, int] = Field(default_factory=dict)
|
||||
cmdr_tax: int = 0
|
||||
mana_available: int = 0
|
||||
mana_pool: Dict[str, int] = Field(default_factory=dict)
|
||||
# Full zone contents
|
||||
hand: List[str] = Field(default_factory=list)
|
||||
hand_count: int = 0
|
||||
battlefield: List[Dict] = Field(default_factory=list)
|
||||
graveyard: List[str] = Field(default_factory=list)
|
||||
exile: List[str] = Field(default_factory=list)
|
||||
command_zone: List[str] = Field(default_factory=list)
|
||||
library_count: int = 0
|
||||
|
||||
|
||||
class DecideRequest(BaseModel):
|
||||
"""Full Forge game state for live decision-making."""
|
||||
# Game metadata
|
||||
game_id: str = ""
|
||||
turn: int = 1
|
||||
phase: str = "main_1"
|
||||
active_player: int = 0
|
||||
priority_player: int = 0
|
||||
# Per-player state with full zones
|
||||
players: List[PlayerZoneState] = Field(default_factory=list)
|
||||
# Stack (spells/abilities currently resolving)
|
||||
stack: List[Dict] = Field(default_factory=list)
|
||||
# Legal actions from Forge
|
||||
legal_actions: List[Dict] = Field(default_factory=list)
|
||||
# Inference params
|
||||
playstyle: str = "midrange"
|
||||
greedy: bool = False
|
||||
temperature: float = 1.0
|
||||
# Optional commander info
|
||||
commander: str = ""
|
||||
deck_name: str = ""
|
||||
|
||||
|
||||
class DecideResponse(BaseModel):
|
||||
"""Action decision with metadata for online learning."""
|
||||
action: str
|
||||
action_index: int
|
||||
confidence: float
|
||||
log_prob: float = 0.0
|
||||
value: float = 0.0
|
||||
probabilities: Dict[str, float] = Field(default_factory=dict)
|
||||
inference_ms: float = 0.0
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class CollectRequest(BaseModel):
|
||||
"""Batch of online learning tuples from live Forge games."""
|
||||
tuples: List[Dict] = Field(default_factory=list)
|
||||
count: int = 0
|
||||
|
||||
|
||||
class RewardRequest(BaseModel):
|
||||
"""End-of-game reward submission."""
|
||||
game_id: str
|
||||
reward: float
|
||||
winner_seat: int = -1
|
||||
turns_played: int = 0
|
||||
reason: str = "" # e.g., "life", "commander_damage", "concede"
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Module-level state (initialized by register_policy_routes)
|
||||
# ================================================================
|
||||
|
||||
_policy_service = None
|
||||
_online_store = None
|
||||
_session_stats = {
|
||||
"total_decisions": 0,
|
||||
"total_tuples_collected": 0,
|
||||
"total_rewards_submitted": 0,
|
||||
"games_completed": 0,
|
||||
"session_start": 0.0,
|
||||
"last_decision_ms": 0.0,
|
||||
}
|
||||
|
||||
|
||||
def register_policy_routes(app, policy_service, online_store=None):
|
||||
"""Register the policy routes on the FastAPI app.
|
||||
|
||||
Args:
|
||||
app: FastAPI application instance
|
||||
policy_service: PolicyInferenceService from ml.serving.policy_server
|
||||
online_store: Optional OnlineLearningStore for tuple collection
|
||||
"""
|
||||
global _policy_service, _online_store
|
||||
_policy_service = policy_service
|
||||
_online_store = online_store
|
||||
_session_stats["session_start"] = time.time()
|
||||
app.include_router(router)
|
||||
logger.info("Policy routes registered (online_store=%s)",
|
||||
"enabled" if online_store else "disabled")
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/decide
|
||||
# ================================================================
|
||||
|
||||
@router.post("/decide", response_model=DecideResponse)
|
||||
async def decide(req: DecideRequest):
|
||||
"""Accept full Forge game state, run policy inference, return action.
|
||||
|
||||
This is the primary endpoint for live Forge IPC. The Java
|
||||
PolicyClient calls this at each decision point during a real
|
||||
Forge game.
|
||||
|
||||
Returns the macro-action plus log_prob and value estimate
|
||||
needed for online PPO updates.
|
||||
"""
|
||||
if _policy_service is None or not _policy_service._loaded:
|
||||
raise HTTPException(
|
||||
503,
|
||||
detail="Policy model not loaded"
|
||||
)
|
||||
|
||||
t_start = time.time()
|
||||
_session_stats["total_decisions"] += 1
|
||||
|
||||
# Convert request to snapshot dict for the encoder
|
||||
snapshot = req.dict(exclude={"temperature", "greedy", "playstyle"})
|
||||
|
||||
# Map phase names from Forge conventions
|
||||
snapshot["phase"] = _map_forge_phase(req.phase)
|
||||
snapshot["active_seat"] = req.active_player
|
||||
|
||||
result = _policy_service.predict(
|
||||
snapshot,
|
||||
playstyle=req.playstyle,
|
||||
temperature=req.temperature,
|
||||
greedy=req.greedy,
|
||||
)
|
||||
|
||||
elapsed_ms = (time.time() - t_start) * 1000
|
||||
_session_stats["last_decision_ms"] = elapsed_ms
|
||||
|
||||
if "error" in result:
|
||||
raise HTTPException(500, detail=result["error"])
|
||||
|
||||
# Add log_prob and value estimate for online learning
|
||||
log_prob = 0.0
|
||||
value = 0.0
|
||||
try:
|
||||
import math
|
||||
conf = result.get("confidence", 0.5)
|
||||
log_prob = math.log(max(conf, 1e-8))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return DecideResponse(
|
||||
action=result["action"],
|
||||
action_index=result["action_index"],
|
||||
confidence=result["confidence"],
|
||||
log_prob=round(log_prob, 6),
|
||||
value=round(value, 6),
|
||||
probabilities=result.get("probabilities", {}),
|
||||
inference_ms=round(elapsed_ms, 2),
|
||||
)
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/collect
|
||||
# ================================================================
|
||||
|
||||
@router.post("/collect")
|
||||
async def collect_tuples(req: CollectRequest):
|
||||
"""Accept a batch of (state, action, reward) tuples from live games
|
||||
for online PPO updates.
|
||||
"""
|
||||
count = len(req.tuples)
|
||||
_session_stats["total_tuples_collected"] += count
|
||||
|
||||
if _online_store is not None:
|
||||
try:
|
||||
_online_store.add_tuples(req.tuples)
|
||||
except Exception as e:
|
||||
logger.error("Failed to store tuples: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
"accepted": count,
|
||||
"total_buffered": (
|
||||
_online_store.size() if _online_store else count
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# POST /api/policy/reward
|
||||
# ================================================================
|
||||
|
||||
@router.post("/reward")
|
||||
async def submit_reward(req: RewardRequest):
|
||||
"""Submit end-of-game reward for all tuples in a game.
|
||||
Propagates the terminal reward back through the game's trajectory.
|
||||
"""
|
||||
_session_stats["total_rewards_submitted"] += 1
|
||||
_session_stats["games_completed"] += 1
|
||||
|
||||
if _online_store is not None:
|
||||
try:
|
||||
_online_store.assign_reward(
|
||||
game_id=req.game_id,
|
||||
reward=req.reward,
|
||||
winner_seat=req.winner_seat,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("Failed to assign reward: %s", e)
|
||||
raise HTTPException(500, detail=str(e))
|
||||
|
||||
return {
|
||||
"game_id": req.game_id,
|
||||
"reward": req.reward,
|
||||
"status": "accepted",
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# GET /api/policy/health
|
||||
# ================================================================
|
||||
|
||||
@router.get("/health")
|
||||
async def policy_health():
|
||||
"""Readiness check for the live Forge IPC loop."""
|
||||
model_loaded = (
|
||||
_policy_service is not None and _policy_service._loaded
|
||||
)
|
||||
return {
|
||||
"ready": model_loaded,
|
||||
"status": "ok" if model_loaded else "degraded",
|
||||
"model_loaded": model_loaded,
|
||||
"online_store": _online_store is not None,
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# GET /api/policy/stats
|
||||
# ================================================================
|
||||
|
||||
@router.get("/stats")
|
||||
async def policy_stats():
|
||||
"""Online learning session statistics."""
|
||||
uptime = time.time() - _session_stats["session_start"]
|
||||
return {
|
||||
**_session_stats,
|
||||
"uptime_seconds": round(uptime, 1),
|
||||
"decisions_per_second": (
|
||||
round(_session_stats["total_decisions"] / max(uptime, 1), 2)
|
||||
),
|
||||
"online_store_size": (
|
||||
_online_store.size() if _online_store else 0
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
# ================================================================
|
||||
# Helpers
|
||||
# ================================================================
|
||||
|
||||
_FORGE_PHASE_MAP = {
|
||||
"UNTAP": "main_1",
|
||||
"UPKEEP": "main_1",
|
||||
"DRAW": "main_1",
|
||||
"MAIN1": "main_1",
|
||||
"BEGIN_COMBAT": "combat",
|
||||
"DECLARE_ATTACKERS": "combat",
|
||||
"DECLARE_BLOCKERS": "combat",
|
||||
"DAMAGE": "combat",
|
||||
"COMBAT_DAMAGE": "combat",
|
||||
"END_COMBAT": "combat",
|
||||
"MAIN2": "main_2",
|
||||
"END": "end",
|
||||
"END_STEP": "end",
|
||||
"CLEANUP": "end",
|
||||
}
|
||||
|
||||
|
||||
def _map_forge_phase(phase: str) -> str:
|
||||
"""Map Forge phase string to ML GamePhase enum value."""
|
||||
return _FORGE_PHASE_MAP.get(phase.upper(), "main_1")
|
||||
+3
-4
@@ -15,9 +15,8 @@ from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from models.state import CFG
|
||||
from services.precon_service import download_precon_database, PRECON_INDEX
|
||||
from services.logging import log
|
||||
import routes.shared as _shared # module reference, not value import
|
||||
from routes.shared import CFG, download_precon_database, log
|
||||
from services.precon_service import _get_precon_dir
|
||||
|
||||
router = APIRouter(tags=["precon"])
|
||||
@@ -26,7 +25,7 @@ router = APIRouter(tags=["precon"])
|
||||
@router.get("/api/lab/precons")
|
||||
async def list_precons():
|
||||
"""List all available precon decks."""
|
||||
return {"precons": PRECON_INDEX} # reads current value every call
|
||||
return {"precons": _shared.PRECON_INDEX} # reads current value every call
|
||||
|
||||
|
||||
@router.post("/api/lab/precons/install")
|
||||
|
||||
+7
-7
@@ -22,13 +22,13 @@ from urllib.error import URLError
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi import Request as FastAPIRequest
|
||||
|
||||
from models.state import CFG
|
||||
from services.card_analysis import _detect_card_roles
|
||||
from services.database import _get_db_conn, _row_to_dict
|
||||
from services.deck_service import _to_edhrec_slug
|
||||
from services.import_helpers import _edhrec_cache_get, _edhrec_cache_set
|
||||
from services.logging import log_scan, log_deckgen
|
||||
from services.scryfall import _API_HEADERS, _enrich_from_scryfall, _scryfall_fuzzy_lookup
|
||||
from routes.shared import (
|
||||
CFG, log_scan, log_deckgen,
|
||||
_get_db_conn, _row_to_dict, _enrich_from_scryfall,
|
||||
_scryfall_fuzzy_lookup, _detect_card_roles,
|
||||
_to_edhrec_slug, _API_HEADERS,
|
||||
_edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
|
||||
router = APIRouter(tags=["scanner"])
|
||||
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
routes/shared.py
|
||||
================
|
||||
Single source of truth for everything every router needs.
|
||||
|
||||
Contains:
|
||||
- Config class + CFG singleton
|
||||
- Logging setup + all named loggers
|
||||
- FastAPI app instance
|
||||
- In-memory state (active_batches, COMMANDER_META, PRECON_INDEX, AI_PROFILES)
|
||||
- All Pydantic request/response models
|
||||
- SQLite DB helpers (_get_db_conn, init_collection_db)
|
||||
- Scryfall cache + enrichment helpers
|
||||
- Collection helpers (_detect_card_roles, _classify_card_type,
|
||||
_build_collection_filters, _parse_finish, _parse_csv_content,
|
||||
_parse_text_line, _auto_infer_mapping, _row_to_dict, _add_image_url,
|
||||
_snake_to_camel, VALID_SORT_FIELDS, _JSON_FIELDS)
|
||||
- Deck helpers (_get_deck_or_404, _compute_deck_analysis, _check_ratio_limit,
|
||||
_TYPE_TARGETS, _TYPE_PRIORITY, _classify_card_type)
|
||||
- Import helpers (_import_from_url, _fetch_archidekt_deck,
|
||||
_fetch_edhrec_average, _parse_text_decklist, _save_profile_to_dck,
|
||||
_to_edhrec_slug, _http_get, _API_HEADERS, _edhrec_cache_get,
|
||||
_edhrec_cache_set)
|
||||
- Precon helpers (PRECON_DIR, PRECON_INDEX, download_precon_database,
|
||||
load_precon_index, GITHUB_PRECON_URL, PRECON_CACHE_HOURS)
|
||||
- Sim helpers (build_java_command, get_java17, BatchState, parse_dck_file,
|
||||
AI_PROFILES, run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _load_deck_cards_by_name, _get_deepseek_brain)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from urllib.request import urlopen, Request
|
||||
from urllib.error import URLError
|
||||
|
||||
try:
|
||||
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request as FastAPIRequest
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
except ImportError:
|
||||
print("ERROR: FastAPI not installed. Run: pip install fastapi uvicorn")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Configuration
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Configuration (imported from models/state)
|
||||
from models.state import Config, CFG
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Logging Setup
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
if root_logger.handlers:
|
||||
return
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach")
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner")
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache")
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# In-Memory State
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# In-Memory State (imported from models/state)
|
||||
from models.state import (
|
||||
BatchState, active_batches, COMMANDER_META,
|
||||
BUILTIN_COMMANDERS, load_commander_meta,
|
||||
)
|
||||
|
||||
|
||||
# Pydantic Models (imported from models/)
|
||||
from models.requests import (
|
||||
StartRequest, ImportUrlRequest, ImportTextRequest, MetaFetchRequest,
|
||||
CreateDeckRequest, UpdateDeckRequest, AddDeckCardRequest,
|
||||
PatchDeckCardRequest, BulkAddRequest, BulkAddRecommendedRequest,
|
||||
DeckGenerationSourceConfig, DeckGenerationRequest,
|
||||
DeckGenV3Request, DeckGenV3SubstituteRequest,
|
||||
)
|
||||
from models.responses import (
|
||||
StartResponse, StatusResponse, DeckInfo, GeneratedDeckCard,
|
||||
)
|
||||
|
||||
|
||||
# Collection Database (imported from services/database.py)
|
||||
from services.database import (
|
||||
COLLECTION_DB_PATH, _get_db_conn, init_collection_db,
|
||||
_JSON_FIELDS, VALID_SORT_FIELDS,
|
||||
_row_to_dict, _snake_to_camel, _add_image_url, _build_collection_filters,
|
||||
)
|
||||
# Card Role Detection (imported from services/card_analysis.py)
|
||||
from services.card_analysis import _detect_card_roles
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Helpers (imported from services/deck_service.py)
|
||||
from services.deck_service import (
|
||||
_TYPE_PRIORITY, _TYPE_TARGETS, _classify_card_type, _get_deck_or_404,
|
||||
_compute_deck_analysis, _check_ratio_limit, _to_edhrec_slug,
|
||||
parse_dck_file, _save_profile_to_dck, _load_deck_cards_by_name,
|
||||
)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache (imported from services/scryfall.py)
|
||||
from services.scryfall import (
|
||||
SCRYFALL_CACHE_DB_PATH, SCRYFALL_CACHE_TTL_SECONDS, _API_HEADERS,
|
||||
_ScryfallCache, _scryfall_cache, _scryfall_lock, _scryfall_last_call,
|
||||
_scryfall_rate_limit, _fetch_scryfall_api, _enrich_from_scryfall, _scryfall_fuzzy_lookup,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Import Helpers (extracted to services/import_helpers.py)
|
||||
from services.import_helpers import (
|
||||
_http_get, _fetch_archidekt_deck, _fetch_edhrec_average,
|
||||
_import_from_url, _parse_text_decklist, _save_profile_to_dck,
|
||||
_edhrec_cache, _EDHREC_CACHE_TTL, _edhrec_cache_get, _edhrec_cache_set,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Collection Import Helpers (extracted to services/import_service.py)
|
||||
from services.import_service import (
|
||||
_parse_finish, _parse_text_line, _auto_infer_mapping, _parse_csv_content,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Precon Helpers (extracted to services/precon_service.py)
|
||||
from services.precon_service import (
|
||||
PRECON_DIR, PRECON_INDEX, GITHUB_PRECON_URL, PRECON_CACHE_HOURS,
|
||||
load_precon_index, _sanitize_filename, _deck_to_dck, download_precon_database,
|
||||
_get_precon_dir,
|
||||
)
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Profiles (imported from models/state)
|
||||
from models.state import AI_PROFILES
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Java / Sim Helpers (extracted to services/forge_runner.py)
|
||||
from services.forge_runner import (
|
||||
_find_java17, get_java17, build_java_command,
|
||||
run_batch_subprocess, _run_process_blocking,
|
||||
_run_deepseek_batch_thread, _get_deepseek_brain,
|
||||
)
|
||||
|
||||
_ml_logging_enabled = True
|
||||
@@ -85,7 +85,7 @@ def start_batch(base_url, decks, num_games, playstyle, threads):
|
||||
def poll_batch(base_url, batch_id, poll_interval=5):
|
||||
"""Poll batch status until complete. Returns final status."""
|
||||
while True:
|
||||
result = api_call(base_url, f"/api/lab/status?batchId={batch_id}")
|
||||
result = api_call(base_url, f"/api/lab/status/{batch_id}")
|
||||
if result is None:
|
||||
time.sleep(poll_interval)
|
||||
continue
|
||||
|
||||
@@ -136,28 +136,6 @@ def init_collection_db() -> None:
|
||||
FOREIGN KEY (deck_id) REFERENCES decks(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS coach_sessions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
deck_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
model_used TEXT DEFAULT '',
|
||||
prompt_tokens INTEGER DEFAULT 0,
|
||||
completion_tokens INTEGER DEFAULT 0,
|
||||
summary TEXT DEFAULT '',
|
||||
goals_json TEXT DEFAULT '{}',
|
||||
cuts_json TEXT DEFAULT '[]',
|
||||
adds_json TEXT DEFAULT '[]',
|
||||
heuristic_hints_json TEXT DEFAULT '[]',
|
||||
mana_base_advice TEXT DEFAULT '',
|
||||
raw_text TEXT DEFAULT '',
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_session_id ON coach_sessions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_deck_id ON coach_sessions(deck_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_cs_timestamp ON coach_sessions(timestamp);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_deck_id ON deck_cards(deck_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dc_scryfall_id ON deck_cards(scryfall_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_dk_commander ON decks(commander_scryfall_id);
|
||||
@@ -324,105 +302,3 @@ def _build_collection_filters(
|
||||
conditions.append("quantity <= ?"); params.append(qtyMax)
|
||||
where_str = ("WHERE " + " AND ".join(conditions)) if conditions else ""
|
||||
return where_str, params
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Coach Session DB Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def save_coach_session(session_data: dict) -> int:
|
||||
"""Insert or update a coach session in SQLite. Returns the row id."""
|
||||
conn = _get_db_conn()
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM coach_sessions WHERE session_id = ?",
|
||||
(session_data["session_id"],),
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"""UPDATE coach_sessions SET
|
||||
deck_id=?, timestamp=?, model_used=?, prompt_tokens=?,
|
||||
completion_tokens=?, summary=?, goals_json=?, cuts_json=?,
|
||||
adds_json=?, heuristic_hints_json=?, mana_base_advice=?,
|
||||
raw_text=?
|
||||
WHERE session_id=?""",
|
||||
(
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
session_data["session_id"],
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return existing["id"]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO coach_sessions
|
||||
(session_id, deck_id, timestamp, model_used, prompt_tokens,
|
||||
completion_tokens, summary, goals_json, cuts_json, adds_json,
|
||||
heuristic_hints_json, mana_base_advice, raw_text)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
session_data["session_id"],
|
||||
session_data.get("deck_id", ""),
|
||||
session_data.get("timestamp", ""),
|
||||
session_data.get("model_used", ""),
|
||||
session_data.get("prompt_tokens", 0),
|
||||
session_data.get("completion_tokens", 0),
|
||||
session_data.get("summary", ""),
|
||||
session_data.get("goals_json", "{}"),
|
||||
session_data.get("cuts_json", "[]"),
|
||||
session_data.get("adds_json", "[]"),
|
||||
session_data.get("heuristic_hints_json", "[]"),
|
||||
session_data.get("mana_base_advice", ""),
|
||||
session_data.get("raw_text", ""),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return cur.lastrowid
|
||||
|
||||
|
||||
def load_coach_session(session_id: str) -> Optional[dict]:
|
||||
"""Load a single coach session by session_id."""
|
||||
conn = _get_db_conn()
|
||||
row = conn.execute(
|
||||
"SELECT * FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def list_coach_sessions(deck_id: str = None) -> list:
|
||||
"""List coach sessions, optionally filtered by deck_id."""
|
||||
conn = _get_db_conn()
|
||||
if deck_id:
|
||||
rows = conn.execute(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions WHERE deck_id = ? ORDER BY timestamp DESC",
|
||||
(deck_id,),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT session_id, deck_id, timestamp, summary, created_at "
|
||||
"FROM coach_sessions ORDER BY timestamp DESC"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def delete_coach_session(session_id: str) -> bool:
|
||||
"""Delete a coach session. Returns True if a row was deleted."""
|
||||
conn = _get_db_conn()
|
||||
cur = conn.execute(
|
||||
"DELETE FROM coach_sessions WHERE session_id = ?", (session_id,)
|
||||
)
|
||||
conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
@@ -204,7 +204,6 @@ def build_java_command(
|
||||
cmd += ['--aiSimplified']
|
||||
if ai_think_time_ms > 0:
|
||||
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
|
||||
cmd += ['--ml-log']
|
||||
if max_queue_depth > 0:
|
||||
cmd += ['--maxQueueDepth', str(max_queue_depth)]
|
||||
return cmd
|
||||
@@ -317,7 +316,7 @@ def _get_deepseek_brain():
|
||||
model=os.environ.get("BRAIN_MODEL", "gpt-oss:20b"),
|
||||
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
|
||||
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
|
||||
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
|
||||
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "120.0")),
|
||||
)
|
||||
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
|
||||
cfg.log_dir = os.path.normpath(cfg.log_dir)
|
||||
@@ -361,7 +360,7 @@ def _run_deepseek_batch_thread(
|
||||
results_dir = CFG.results_dir
|
||||
if os.path.isdir(results_dir):
|
||||
for fname in sorted(os.listdir(results_dir), reverse=True):
|
||||
if fname.startswith('ml-decision-') and fname.endswith('.json'):
|
||||
if fname.startswith('batch-') and fname.endswith('.json'):
|
||||
try:
|
||||
with open(os.path.join(results_dir, fname), 'r') as rf:
|
||||
past = json.loads(rf.read())
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
"""
|
||||
services/logging.py
|
||||
====================
|
||||
Single source of truth for application logging configuration and named loggers.
|
||||
|
||||
Moved from routes/shared.py so that every layer (routes, services, CLI
|
||||
entry-points) can import logging without pulling in the full shared module.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# ── Format / rotation constants ───────────────────────────────────────────────
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
if root_logger.handlers:
|
||||
return
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
# ── Named loggers ────────────────────────────────────────────────────────────
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch")
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim")
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach")
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection")
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner")
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml")
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache")
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx")
|
||||
|
||||
# ── ML logging toggle ────────────────────────────────────────────────────────
|
||||
_ml_logging_enabled = True
|
||||
@@ -24,7 +24,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Ollama exposes an OpenAI-compatible endpoint
|
||||
DEFAULT_BASE_URL = "http://localhost:11434/v1"
|
||||
DEFAULT_MODEL = "qwen2.5:7b"
|
||||
DEFAULT_MODEL = "gpt-oss:20b"
|
||||
|
||||
|
||||
def _get_client(base_url: str = DEFAULT_BASE_URL) -> OpenAI:
|
||||
|
||||
@@ -63,40 +63,6 @@ class MemoryEntry:
|
||||
latency_ms: float
|
||||
|
||||
|
||||
# ── Canned narration lines (by personality key) ───────────────────────
|
||||
|
||||
_CANNED_NARRATIONS: dict[str, dict[str, str]] = {
|
||||
"aggro_timmy": {
|
||||
"play_land": "More mana, more mayhem!",
|
||||
"cast_spell": "Here comes the pain!",
|
||||
"attack": "ATTACK! Take everything!",
|
||||
"pass_priority": "Fine, go… but I'm watching you.",
|
||||
"default": "Let's smash something!",
|
||||
},
|
||||
"control_spike": {
|
||||
"play_land": "Mana secured. Proceeding.",
|
||||
"cast_spell": "Optimal play. As expected.",
|
||||
"attack": "Calculated aggression — acceptable risk.",
|
||||
"pass_priority": "Holding interaction mana. Predictable of you.",
|
||||
"default": "Inevitable victory approaches.",
|
||||
},
|
||||
"combo_johnny": {
|
||||
"play_land": "One step closer to the combo!",
|
||||
"cast_spell": "Assembling the pieces…",
|
||||
"attack": "Buying time until the combo fires.",
|
||||
"pass_priority": "Patience. The combo is coming.",
|
||||
"default": "The pieces are falling into place!",
|
||||
},
|
||||
"political_negotiator": {
|
||||
"play_land": "Just tapping a land. Nothing threatening here.",
|
||||
"cast_spell": "This helps us all, really.",
|
||||
"attack": "I'm afraid I must — for the good of the table.",
|
||||
"pass_priority": "I'll let this resolve. For now.",
|
||||
"default": "Let's all be reasonable about this.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── AIOpponent ─────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
@@ -443,9 +409,40 @@ class AIOpponent:
|
||||
|
||||
# ── Canned Narration ───────────────────────────────────────────────
|
||||
|
||||
_CANNED: dict[str, dict[str, str]] = {
|
||||
"aggro_timmy": {
|
||||
"play_land": "More mana, more mayhem!",
|
||||
"cast_spell": "Here comes the pain!",
|
||||
"attack": "ATTACK! Take everything!",
|
||||
"pass_priority": "Fine, go… but I’m watching you.",
|
||||
"default": "Let’s smash something!",
|
||||
},
|
||||
"control_spike": {
|
||||
"play_land": "Mana secured. Proceeding.",
|
||||
"cast_spell": "Optimal play. As expected.",
|
||||
"attack": "Calculated aggression — acceptable risk.",
|
||||
"pass_priority": "Holding interaction mana. Predictable of you.",
|
||||
"default": "Inevitable victory approaches.",
|
||||
},
|
||||
"combo_johnny": {
|
||||
"play_land": "One step closer to the combo!",
|
||||
"cast_spell": "Assembling the pieces…",
|
||||
"attack": "Buying time until the combo fires.",
|
||||
"pass_priority": "Patience. The combo is coming.",
|
||||
"default": "The pieces are falling into place!",
|
||||
},
|
||||
"political_negotiator": {
|
||||
"play_land": "Just tapping a land. Nothing threatening here.",
|
||||
"cast_spell": "This helps us all, really.",
|
||||
"attack": "I’m afraid I must — for the good of the table.",
|
||||
"pass_priority": "I’ll let this resolve. For now.",
|
||||
"default": "Let’s all be reasonable about this.",
|
||||
},
|
||||
}
|
||||
|
||||
def _canned_narration(self, move_desc: str) -> str:
|
||||
"""Return a canned flavor line matching personality and move category."""
|
||||
canned = _CANNED_NARRATIONS.get(self.personality.key, {})
|
||||
canned = self._CANNED.get(self.personality.key, {})
|
||||
desc_lower = move_desc.lower()
|
||||
for key in ("play_land", "cast_spell", "attack", "pass_priority"):
|
||||
if key.replace("_", " ") in desc_lower or key in desc_lower:
|
||||
|
||||
@@ -9,8 +9,8 @@ The LLM receives a structured game-state snapshot and returns a JSON
|
||||
action describing what to do this turn (play land, cast spell, attack,
|
||||
hold/pass, use removal, use board wipe, cast ramp, cast commander).
|
||||
|
||||
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at localhost:11434
|
||||
ollama run deepseek-r1:8b
|
||||
Default target: GPT-OSS 20B (Q4_K_M) via Ollama at 192.168.0.240:11434
|
||||
ollama run gpt-oss:20b
|
||||
|
||||
Also supports:
|
||||
- LM Studio (OpenAI-compatible API) — set api_base to LM Studio URL
|
||||
@@ -58,28 +58,28 @@ class DeepSeekConfig:
|
||||
|
||||
Example — LM Studio::
|
||||
config = DeepSeekConfig(
|
||||
api_base="http://localhost:11434",
|
||||
api_base="http://192.168.0.240:11434",
|
||||
model="deepseek-r1-distill-qwen-8b",
|
||||
max_tokens=2048, # R1 needs room for <think> blocks
|
||||
)
|
||||
|
||||
Example — Ollama (default)::
|
||||
config = DeepSeekConfig() # points at localhost:11434 / deepseek-r1:8b
|
||||
config = DeepSeekConfig() # points at 192.168.0.240:11434 / gpt-oss:20b
|
||||
"""
|
||||
|
||||
# API endpoint — Ollama OpenAI-compatible base (NO /v1 suffix)
|
||||
api_base: str = "http://localhost:11434"
|
||||
api_base: str = "http://192.168.0.240:11434"
|
||||
# Model name as registered in Ollama
|
||||
model: str = "deepseek-r1:8b"
|
||||
model: str = "gpt-oss:20b"
|
||||
|
||||
# Generation parameters
|
||||
# GPT-OSS 20B does not emit <think> blocks so 1024 tokens is ample
|
||||
temperature: float = 0.3
|
||||
max_tokens: int = 4096
|
||||
max_tokens: int = 1024
|
||||
top_p: float = 0.9
|
||||
|
||||
# Timeouts
|
||||
request_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # Ollama local inference; raise vs LM Studio
|
||||
request_timeout: float = 30.0 # Ollama local inference; raise vs LM Studio
|
||||
fallback_on_timeout: bool = True
|
||||
|
||||
# Caching
|
||||
@@ -91,7 +91,7 @@ class DeepSeekConfig:
|
||||
log_dir: str = ""
|
||||
|
||||
# Retry
|
||||
max_retries: int = 2
|
||||
max_retries: int = 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -543,8 +543,9 @@ class DeepSeekBrain:
|
||||
if models:
|
||||
model_id = models[0].get("id", self.config.model)
|
||||
if model_id:
|
||||
logger.info("Available model: %s (keeping configured: %s)", model_id, self.config.model)
|
||||
self.config.model = model_id
|
||||
logger.info("Auto-detected model: %s", model_id)
|
||||
logger.info("LLM brain connected to %s", self.config.api_base)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("LLM brain connection failed: %s", e)
|
||||
@@ -590,31 +591,25 @@ class DeepSeekBrain:
|
||||
if not self._connected:
|
||||
return self._fallback_action(snapshot, t_start, "not_connected")
|
||||
|
||||
last_err = None
|
||||
for attempt in range(1, self.config.max_retries + 1):
|
||||
try:
|
||||
result = self._call_llm(snapshot)
|
||||
result["source"] = "llm"
|
||||
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
|
||||
try:
|
||||
result = self._call_llm(snapshot)
|
||||
result["source"] = "llm"
|
||||
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
|
||||
|
||||
if self.config.cache_enabled:
|
||||
self._put_cache(cache_key, result)
|
||||
if self.config.cache_enabled:
|
||||
self._put_cache(cache_key, result)
|
||||
|
||||
if self.config.log_decisions:
|
||||
self._log_decision(snapshot, result, player_index)
|
||||
if self.config.log_decisions:
|
||||
self._log_decision(snapshot, result, player_index)
|
||||
|
||||
self._total_latency_ms += result["latency_ms"]
|
||||
return result
|
||||
self._total_latency_ms += result["latency_ms"]
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
logger.warning("LLM call failed (turn %d, attempt %d/%d): %s", turn + 1, attempt, self.config.max_retries, e)
|
||||
if attempt < self.config.max_retries:
|
||||
continue
|
||||
|
||||
if self.config.fallback_on_timeout:
|
||||
return self._fallback_action(snapshot, t_start, str(last_err))
|
||||
raise last_err
|
||||
except Exception as e:
|
||||
logger.warning("LLM call failed (turn %d): %s", turn + 1, e)
|
||||
if self.config.fallback_on_timeout:
|
||||
return self._fallback_action(snapshot, t_start, str(e))
|
||||
raise
|
||||
|
||||
def _call_llm(self, snapshot: dict) -> dict:
|
||||
"""Send game state to LLM and parse response."""
|
||||
@@ -659,10 +654,9 @@ class DeepSeekBrain:
|
||||
text = re.sub(r'\s*```$', '', text)
|
||||
text = text.strip()
|
||||
|
||||
start = text.find('{')
|
||||
end = text.rfind('}')
|
||||
if start != -1 and end != -1 and end > start:
|
||||
text = text[start:end + 1]
|
||||
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
|
||||
if json_match:
|
||||
text = json_match.group(0)
|
||||
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
|
||||
@@ -23,7 +23,7 @@ Usage::
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
|
||||
|
||||
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://localhost:11434"))
|
||||
brain = DeepSeekBrain(DeepSeekConfig(api_base="http://192.168.0.240:11434"))
|
||||
brain.check_connection()
|
||||
engine = DeepSeekGameEngine(brain=brain, ai_player_index=1)
|
||||
result = engine.run(deck_a, deck_b, name_a="My Deck", name_b="DeepSeek AI")
|
||||
|
||||
@@ -28,14 +28,10 @@ from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.models import (
|
||||
Card,
|
||||
CombatState,
|
||||
GameResult,
|
||||
Phase,
|
||||
PHASE_ORDER,
|
||||
Player,
|
||||
PlayerResult,
|
||||
PlayerStats,
|
||||
SORCERY_PHASES,
|
||||
SimState,
|
||||
)
|
||||
from commander_ai_lab.sim.rules import (
|
||||
@@ -64,9 +60,6 @@ class GameEngine:
|
||||
``weights`` explicitly to override (e.g. for A/B testing).
|
||||
"""
|
||||
|
||||
# London mulligan: max times a player may mulligan before forced keep
|
||||
MAX_MULLIGANS = 4
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_turns: int = 25,
|
||||
@@ -74,7 +67,6 @@ class GameEngine:
|
||||
weights: Optional[dict] = None,
|
||||
record_log: bool = False,
|
||||
ml_log: bool = False,
|
||||
mulligan_rule: str = "london",
|
||||
):
|
||||
self.max_turns = max_turns
|
||||
self.starting_life = starting_life
|
||||
@@ -83,7 +75,6 @@ class GameEngine:
|
||||
self.record_log = record_log
|
||||
self.ml_log = ml_log
|
||||
self.ml_decisions: list[dict] = []
|
||||
self.mulligan_rule = mulligan_rule
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Public API
|
||||
@@ -124,7 +115,6 @@ class GameEngine:
|
||||
final_turn = 0
|
||||
|
||||
for turn in range(sim.max_turns):
|
||||
sim.turn = turn
|
||||
turn_entry = {"turn": turn + 1, "phases": []}
|
||||
|
||||
for pi in range(len(sim.players)):
|
||||
@@ -132,106 +122,59 @@ class GameEngine:
|
||||
if p.eliminated:
|
||||
continue
|
||||
p.stats.turns_alive += 1
|
||||
sim.active_player_index = pi
|
||||
phase_events: list[str] = []
|
||||
|
||||
# ── Untap (correct MTG order: untap is first) ──
|
||||
for c in sim.get_battlefield(pi):
|
||||
c.tapped = False
|
||||
c.damage_marked = 0 # clear damage each turn (MTG rule 514.2)
|
||||
|
||||
# ── Draw ──
|
||||
if p.library:
|
||||
drawn = p.library[-1]
|
||||
p.hand.append(p.library.pop())
|
||||
p.stats.cards_drawn += 1
|
||||
if self.record_log:
|
||||
phase_events.append(f"Drew {drawn.name}")
|
||||
|
||||
# ── Capture ML snapshot BEFORE turn actions ──
|
||||
pre_snapshot = None
|
||||
if self.ml_log:
|
||||
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
|
||||
|
||||
# Track spell/damage counts for ML logging
|
||||
# ── Land drop ──
|
||||
land_before = p.stats.lands_played
|
||||
self._play_land(sim, pi)
|
||||
played_land = p.stats.lands_played > land_before
|
||||
if self.record_log and played_land:
|
||||
last_land = next(
|
||||
(c for c in reversed(sim.get_battlefield(pi))
|
||||
if c.is_land()),
|
||||
None,
|
||||
)
|
||||
if last_land:
|
||||
phase_events.append(f"Played land: {last_land.name}")
|
||||
|
||||
# ── Available mana ──
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
# ── Play spells (up to 2) ──
|
||||
spells_before = p.stats.spells_cast
|
||||
damage_before_turn = p.stats.damage_dealt
|
||||
spells_budget = 2 # shared across main1 + main2
|
||||
|
||||
# ── Walk each phase in order ──
|
||||
for phase in PHASE_ORDER:
|
||||
sim.current_phase = phase
|
||||
|
||||
if phase == Phase.UNTAP:
|
||||
for c in sim.get_battlefield(pi):
|
||||
c.tapped = False
|
||||
c.damage_marked = 0 # MTG rule 514.2
|
||||
|
||||
elif phase == Phase.UPKEEP:
|
||||
pass # placeholder — triggers would fire here
|
||||
|
||||
elif phase == Phase.DRAW:
|
||||
if p.library:
|
||||
drawn = p.library[-1]
|
||||
p.hand.append(p.library.pop())
|
||||
p.stats.cards_drawn += 1
|
||||
if self.record_log:
|
||||
phase_events.append(f"Drew {drawn.name}")
|
||||
|
||||
elif phase == Phase.MAIN1:
|
||||
# Land drop (only in main1, per MTG convention)
|
||||
land_before = p.stats.lands_played
|
||||
self._play_land(sim, pi)
|
||||
played_land = p.stats.lands_played > land_before
|
||||
if self.record_log and played_land:
|
||||
last_land = next(
|
||||
(c for c in reversed(sim.get_battlefield(pi))
|
||||
if c.is_land()),
|
||||
None,
|
||||
)
|
||||
if last_land:
|
||||
phase_events.append(f"Played land: {last_land.name}")
|
||||
|
||||
# Play spells (shared budget across main1 + main2)
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
cast = self._play_spells(
|
||||
sim, pi, available_mana,
|
||||
phase_events if self.record_log else None,
|
||||
max_spells=spells_budget,
|
||||
)
|
||||
spells_budget -= cast
|
||||
|
||||
elif phase == Phase.BEGIN_COMBAT:
|
||||
pass # placeholder — "beginning of combat" triggers
|
||||
|
||||
elif phase == Phase.DECLARE_ATTACKERS:
|
||||
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
|
||||
|
||||
elif phase == Phase.DECLARE_BLOCKERS:
|
||||
pass # blocking is handled inside _resolve_combat
|
||||
|
||||
elif phase == Phase.COMBAT_DAMAGE:
|
||||
pass # damage is handled inside _resolve_combat
|
||||
|
||||
elif phase == Phase.END_COMBAT:
|
||||
pass # placeholder — "end of combat" triggers
|
||||
|
||||
elif phase == Phase.MAIN2:
|
||||
# Second main phase: use remaining spell budget
|
||||
if spells_budget > 0:
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
cast = self._play_spells(
|
||||
sim, pi, available_mana,
|
||||
phase_events if self.record_log else None,
|
||||
max_spells=spells_budget,
|
||||
)
|
||||
spells_budget -= cast
|
||||
|
||||
elif phase == Phase.END_STEP:
|
||||
pass # placeholder — "at end of turn" triggers
|
||||
|
||||
elif phase == Phase.CLEANUP:
|
||||
pass # damage clearing already happened in untap for simplicity
|
||||
|
||||
# ── Post-phase bookkeeping ──
|
||||
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
|
||||
played_spell = p.stats.spells_cast > spells_before
|
||||
attacked = p.stats.damage_dealt > damage_before_turn
|
||||
|
||||
# Track board size
|
||||
# ── Track board size ──
|
||||
board_size = len(sim.get_battlefield(pi))
|
||||
if board_size > p.stats.max_board_size:
|
||||
p.stats.max_board_size = board_size
|
||||
|
||||
# ── Combat ──
|
||||
damage_before = p.stats.damage_dealt
|
||||
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
|
||||
attacked = p.stats.damage_dealt > damage_before
|
||||
|
||||
# ── Record ML decision snapshot ──
|
||||
if self.ml_log and pre_snapshot is not None:
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
if attacked:
|
||||
action_type = "attack"
|
||||
elif played_spell:
|
||||
@@ -339,42 +282,6 @@ class GameEngine:
|
||||
# Internal helpers
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Opening-hand helpers
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _count_lands_in_hand(hand: list[Card]) -> int:
|
||||
"""Count lands in a hand of cards."""
|
||||
return sum(1 for c in hand if c.is_land())
|
||||
|
||||
@staticmethod
|
||||
def _should_mulligan(hand: list[Card], mulligan_number: int) -> bool:
|
||||
"""Heuristic: mulligan if opening hand has 0-1 lands or 6-7 lands.
|
||||
|
||||
Becomes more lenient with each successive mulligan — after 2+
|
||||
mulligans, accept 1-land hands to avoid going too low on cards.
|
||||
"""
|
||||
lands = GameEngine._count_lands_in_hand(hand)
|
||||
total = len(hand)
|
||||
if mulligan_number >= 2:
|
||||
# Desperate: keep anything with 1-5 lands
|
||||
return lands < 1 or lands >= total
|
||||
# Standard: keep 2-5 lands in a 7-card hand
|
||||
return lands <= 1 or lands >= 6
|
||||
|
||||
@staticmethod
|
||||
def _pick_bottom_cards(hand: list[Card], count: int, weights: dict) -> list[Card]:
|
||||
"""Choose the worst *count* cards to put on the bottom of library.
|
||||
|
||||
Uses the AI scoring function so the least valuable cards are
|
||||
bottomed — mirrors a real player's London mulligan decision.
|
||||
"""
|
||||
if count <= 0 or count >= len(hand):
|
||||
return []
|
||||
scored = sorted(hand, key=lambda c: score_card(c, weights))
|
||||
return scored[:count]
|
||||
|
||||
def _create_state(
|
||||
self,
|
||||
decks: list[list[Card]],
|
||||
@@ -383,11 +290,6 @@ class GameEngine:
|
||||
) -> SimState:
|
||||
"""Create initial simulation state with shuffled decks and opening hands (N players).
|
||||
|
||||
Implements the London mulligan rule: each player draws 7, decides
|
||||
whether to keep, and if not shuffles back and redraws. After
|
||||
keeping, they put N cards on the bottom of their library where N
|
||||
is the number of times they mulliganed.
|
||||
|
||||
If commander_names is provided, the matching card is flagged
|
||||
is_commander=True and placed in the player's command_zone
|
||||
instead of the library/hand.
|
||||
@@ -406,46 +308,16 @@ class GameEngine:
|
||||
commander_card = c
|
||||
else:
|
||||
remaining.append(c)
|
||||
|
||||
# ── London mulligan loop ──
|
||||
mulligan_count = 0
|
||||
if self.mulligan_rule == "london":
|
||||
for attempt in range(self.MAX_MULLIGANS):
|
||||
random.shuffle(remaining)
|
||||
hand = remaining[:7]
|
||||
if not self._should_mulligan(hand, attempt):
|
||||
break
|
||||
mulligan_count += 1
|
||||
else:
|
||||
# Exhausted all mulligans — forced keep with last draw
|
||||
random.shuffle(remaining)
|
||||
hand = remaining[:7]
|
||||
else:
|
||||
# No mulligan (legacy behavior)
|
||||
random.shuffle(remaining)
|
||||
hand = remaining[:7]
|
||||
|
||||
random.shuffle(remaining)
|
||||
hand = remaining[:7]
|
||||
library = remaining[7:]
|
||||
|
||||
# ── Bottom N cards for London mulligan ──
|
||||
if mulligan_count > 0:
|
||||
bottom = self._pick_bottom_cards(hand, mulligan_count, self.weights)
|
||||
for card in bottom:
|
||||
hand.remove(card)
|
||||
library.insert(0, card) # bottom of library
|
||||
|
||||
opening_lands = self._count_lands_in_hand(hand)
|
||||
player = Player(
|
||||
name=name,
|
||||
life=self.starting_life,
|
||||
owner_id=idx,
|
||||
library=library,
|
||||
hand=hand,
|
||||
stats=PlayerStats(
|
||||
cards_drawn=7,
|
||||
mulligans=mulligan_count,
|
||||
opening_hand_lands=opening_lands,
|
||||
),
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
)
|
||||
if commander_card:
|
||||
player.command_zone.append(commander_card)
|
||||
@@ -482,57 +354,6 @@ class GameEngine:
|
||||
else:
|
||||
owner.graveyard.append(card)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Centralized Damage
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def deal_damage(
|
||||
sim: SimState,
|
||||
amount: int,
|
||||
target_seat: int,
|
||||
source_card: Card | None = None,
|
||||
source_seat: int | None = None,
|
||||
is_combat: bool = False,
|
||||
events: list | None = None,
|
||||
label: str = "",
|
||||
) -> int:
|
||||
"""Apply *amount* damage to a player through the centralized path.
|
||||
|
||||
Handles life reduction, stat tracking, commander damage tracking
|
||||
(both per-seat aggregate and per-card breakdown from PR #89),
|
||||
and elimination checks. Returns the actual damage dealt.
|
||||
"""
|
||||
if amount <= 0:
|
||||
return 0
|
||||
target = sim.players[target_seat]
|
||||
if target.eliminated:
|
||||
return 0
|
||||
|
||||
target.life -= amount
|
||||
target.stats.damage_received += amount
|
||||
if source_seat is not None:
|
||||
sim.players[source_seat].stats.damage_dealt += amount
|
||||
|
||||
# Commander damage tracking — dual-dict model (PR #89):
|
||||
# commander_damage_received[seat] = aggregate per-seat total
|
||||
# commander_damage_by_card[(seat, card_name)] = per-card breakdown
|
||||
if source_card and source_card.is_commander and source_seat is not None:
|
||||
target.commander_damage_received[source_seat] = (
|
||||
target.commander_damage_received.get(source_seat, 0) + amount
|
||||
)
|
||||
key = (source_seat, source_card.name)
|
||||
target.commander_damage_by_card[key] = (
|
||||
target.commander_damage_by_card.get(key, 0) + amount
|
||||
)
|
||||
|
||||
if events is not None and label:
|
||||
events.append(f"{label} ({target.name} now at {target.life} life)")
|
||||
|
||||
if target.life <= 0 or target.is_dead_to_commander_damage():
|
||||
target.eliminated = True
|
||||
return amount
|
||||
|
||||
def _count_untapped_lands(self, sim: SimState, pi: int) -> int:
|
||||
"""Count untapped lands for a player."""
|
||||
return sum(
|
||||
@@ -541,15 +362,12 @@ class GameEngine:
|
||||
if not c.tapped and c.is_land()
|
||||
)
|
||||
|
||||
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None, max_spells: int = 2) -> int:
|
||||
"""Play up to *max_spells* spells from hand, prioritized by AI score.
|
||||
|
||||
Returns the number of spells actually played this call.
|
||||
"""
|
||||
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
|
||||
"""Play up to 2 spells from hand, prioritized by AI score."""
|
||||
p = sim.players[pi]
|
||||
w = self.weights
|
||||
played = 0
|
||||
while played < max_spells:
|
||||
while played < 2:
|
||||
playable = []
|
||||
for i, card in enumerate(p.hand):
|
||||
if card.is_land():
|
||||
@@ -619,22 +437,14 @@ class GameEngine:
|
||||
p.graveyard.append(card)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
|
||||
elif card.is_direct_damage and card.direct_damage_amount > 0:
|
||||
# Direct-damage spell (burn): route through centralized deal_damage()
|
||||
target_seat = self._select_attack_target(sim, pi)
|
||||
if target_seat != -1:
|
||||
target_name = sim.players[target_seat].name
|
||||
self.deal_damage(
|
||||
sim, card.direct_damage_amount, target_seat,
|
||||
source_card=card, source_seat=pi,
|
||||
events=events,
|
||||
label=f"{card.name} deals {card.direct_damage_amount} damage to {target_name}",
|
||||
)
|
||||
elif events is not None:
|
||||
events.append(f"Cast {card.name} (burn, no targets)")
|
||||
p.graveyard.append(card)
|
||||
else:
|
||||
sim.add_to_battlefield(pi, card)
|
||||
# Fix #84/BUG-05: instants and sorceries resolve and go to
|
||||
# graveyard — they are NOT permanents and should never stay
|
||||
# on the battlefield.
|
||||
if card.is_permanent():
|
||||
sim.add_to_battlefield(pi, card)
|
||||
else:
|
||||
self._send_to_graveyard(sim, card, pi)
|
||||
if card.is_creature():
|
||||
p.stats.creatures_played += 1
|
||||
if events is not None:
|
||||
@@ -678,7 +488,6 @@ class GameEngine:
|
||||
f"for {total_cost} mana (tax={tax})"
|
||||
)
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
return played
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
@@ -712,7 +521,7 @@ class GameEngine:
|
||||
c
|
||||
for c in sim.get_battlefield(pi)
|
||||
if (
|
||||
c.is_creature()
|
||||
c.can_attack_or_block()
|
||||
and not c.tapped
|
||||
and (c.turn_played < sim.turn or c.has_keyword("haste"))
|
||||
)
|
||||
@@ -723,7 +532,7 @@ class GameEngine:
|
||||
c
|
||||
for c in sim.get_battlefield(opp_idx)
|
||||
if (
|
||||
c.is_creature()
|
||||
c.can_attack_or_block()
|
||||
and not c.tapped
|
||||
)
|
||||
]
|
||||
@@ -756,8 +565,6 @@ class GameEngine:
|
||||
total_damage = 0
|
||||
used_blockers: set[int] = set()
|
||||
combat_details: list[str] = []
|
||||
# Per-attacker damage to route through deal_damage() individually
|
||||
attacker_damage: list[tuple[Card, int]] = []
|
||||
for atk in attackers:
|
||||
a_pow = atk.get_power()
|
||||
a_tou = atk.get_toughness()
|
||||
@@ -806,12 +613,12 @@ class GameEngine:
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
attacker_damage.append((atk, trample_over))
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
ds_dmg = a_pow if (a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch")) else trample_over
|
||||
attacker_damage.append((atk, ds_dmg))
|
||||
total_damage += ds_dmg
|
||||
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
total_damage += a_pow
|
||||
else:
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("lifelink"):
|
||||
damage_dealt = min(a_pow, blocker.get_toughness()) + trample_over
|
||||
if atk.has_keyword("double strike"):
|
||||
@@ -819,261 +626,21 @@ class GameEngine:
|
||||
p.life += damage_dealt
|
||||
if not blocked:
|
||||
if atk.has_keyword("double strike"):
|
||||
atk_dmg = a_pow * 2
|
||||
total_damage += a_pow * 2
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow * 2
|
||||
else:
|
||||
atk_dmg = a_pow
|
||||
total_damage += a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += a_pow
|
||||
attacker_damage.append((atk, atk_dmg))
|
||||
total_damage += atk_dmg
|
||||
# Route each attacker's damage through deal_damage() for
|
||||
# centralized life/stats/commander tracking
|
||||
for atk_card, dmg in attacker_damage:
|
||||
if dmg > 0:
|
||||
self.deal_damage(
|
||||
sim, dmg, opp_idx,
|
||||
source_card=atk_card, source_seat=pi,
|
||||
is_combat=True,
|
||||
)
|
||||
opp.life -= total_damage
|
||||
p.stats.damage_dealt += total_damage
|
||||
opp.stats.damage_received += total_damage
|
||||
if events is not None and total_damage > 0:
|
||||
# Compute commander damage annotation from the per-card dict
|
||||
total_cmd_damage = sum(
|
||||
dmg for (atk_card, dmg) in attacker_damage
|
||||
if atk_card.is_commander and dmg > 0
|
||||
)
|
||||
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
|
||||
events.append(
|
||||
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
|
||||
)
|
||||
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
|
||||
events.extend(combat_details)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Split combat methods for turn_manager priority windows
|
||||
# (Issue #86 Item 1). The original _resolve_combat() above
|
||||
# remains as the backward-compatible atomic path for run_n().
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
def assign_attackers(
|
||||
self, sim: SimState, pi: int, events: list | None = None,
|
||||
) -> Optional[CombatState]:
|
||||
"""Phase 1 of split combat: choose attackers, populate CombatState.
|
||||
|
||||
Returns a CombatState with attackers set and tapped, or None if
|
||||
no valid attack is possible. Does NOT deal any damage.
|
||||
"""
|
||||
p = sim.players[pi]
|
||||
opp_idx = self._select_attack_target(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return None
|
||||
|
||||
opp = sim.players[opp_idx]
|
||||
w = self.weights
|
||||
my_creatures = [
|
||||
c
|
||||
for c in sim.get_battlefield(pi)
|
||||
if (
|
||||
c.is_creature()
|
||||
and not c.tapped
|
||||
and (c.turn_played < sim.turn or c.has_keyword("haste"))
|
||||
)
|
||||
]
|
||||
if not my_creatures:
|
||||
return None
|
||||
|
||||
opp_blockers = [
|
||||
c
|
||||
for c in sim.get_battlefield(opp_idx)
|
||||
if c.is_creature() and not c.tapped
|
||||
]
|
||||
|
||||
attackers = []
|
||||
total_my_power = sum(c.get_power() for c in my_creatures)
|
||||
for atk in my_creatures:
|
||||
a_pow = atk.get_power()
|
||||
a_tou = atk.get_toughness()
|
||||
has_flying = atk.has_keyword("flying")
|
||||
has_trample = atk.has_keyword("trample")
|
||||
has_haste = atk.has_keyword("haste")
|
||||
|
||||
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
|
||||
attackers.append(atk)
|
||||
atk.tapped = True
|
||||
continue
|
||||
|
||||
can_die_profitably = any(
|
||||
(
|
||||
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
|
||||
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
|
||||
and b.get_toughness() > a_pow
|
||||
)
|
||||
for b in opp_blockers
|
||||
)
|
||||
if not can_die_profitably or p.life > 25:
|
||||
attackers.append(atk)
|
||||
atk.tapped = True
|
||||
|
||||
if not attackers:
|
||||
return None
|
||||
|
||||
if events is not None:
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
|
||||
|
||||
# Build and store CombatState
|
||||
combat = CombatState(
|
||||
defending_seat=opp_idx,
|
||||
attackers={atk.id: opp_idx for atk in attackers},
|
||||
active=True,
|
||||
)
|
||||
sim.combat = combat
|
||||
return combat
|
||||
|
||||
def assign_blockers(
|
||||
self, sim: SimState, pi: int, combat: CombatState,
|
||||
events: list | None = None,
|
||||
) -> None:
|
||||
"""Phase 2 of split combat: assign blockers into CombatState.
|
||||
|
||||
Populates combat.blockers and combat.player_damage.
|
||||
Does NOT apply any damage to players or creatures yet.
|
||||
"""
|
||||
opp_idx = combat.defending_seat
|
||||
opp_blockers = [
|
||||
c for c in sim.get_battlefield(opp_idx)
|
||||
if c.is_creature() and not c.tapped
|
||||
]
|
||||
used_blockers: set[int] = set()
|
||||
|
||||
for atk_id in combat.attackers:
|
||||
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
|
||||
if atk is None:
|
||||
continue
|
||||
a_pow = atk.get_power()
|
||||
has_flying = atk.has_keyword("flying")
|
||||
valid_blockers = [
|
||||
b for b in opp_blockers
|
||||
if b.id not in used_blockers
|
||||
and (not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
|
||||
]
|
||||
|
||||
blocker = None
|
||||
if valid_blockers and (not atk.has_keyword("menace") or len(valid_blockers) >= 2):
|
||||
if atk.has_keyword("menace") and len(valid_blockers) >= 2:
|
||||
valid_blockers.sort(key=lambda b: -b.get_toughness())
|
||||
blocker = valid_blockers[0]
|
||||
used_blockers.add(valid_blockers[1].id)
|
||||
else:
|
||||
blocker = next(
|
||||
(b for b in valid_blockers if b.get_toughness() > a_pow),
|
||||
None,
|
||||
)
|
||||
opp = sim.players[opp_idx]
|
||||
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
|
||||
blocker = valid_blockers[0]
|
||||
|
||||
if blocker:
|
||||
used_blockers.add(blocker.id)
|
||||
combat.blockers[atk_id] = [blocker.id]
|
||||
if events is not None:
|
||||
events.append(f"{atk.name} blocked by {blocker.name}")
|
||||
else:
|
||||
# Unblocked — full damage goes to player
|
||||
combat.player_damage[atk_id] = a_pow
|
||||
|
||||
def resolve_combat_damage(
|
||||
self, sim: SimState, pi: int, combat: CombatState,
|
||||
first_strike_only: bool = False,
|
||||
events: list | None = None,
|
||||
) -> None:
|
||||
"""Phase 3 of split combat: apply damage from CombatState.
|
||||
|
||||
When first_strike_only=True, only processes creatures with
|
||||
first_strike or double_strike. When False, processes all
|
||||
remaining (skipping first-strikers if already resolved).
|
||||
"""
|
||||
opp_idx = combat.defending_seat
|
||||
p = sim.players[pi]
|
||||
total_damage = 0
|
||||
attacker_damage: list[tuple[Card, int]] = []
|
||||
combat_details: list[str] = []
|
||||
|
||||
for atk_id, def_seat in list(combat.attackers.items()):
|
||||
atk = next((c for bf in sim.battlefields for c in bf if c.id == atk_id), None)
|
||||
if atk is None:
|
||||
continue # removed by instant before damage
|
||||
|
||||
is_fs = atk.has_keyword("first_strike") or atk.has_keyword("double_strike")
|
||||
if first_strike_only and not is_fs:
|
||||
continue
|
||||
if not first_strike_only and is_fs and combat.first_strike_resolved:
|
||||
if not atk.has_keyword("double_strike"):
|
||||
continue # already dealt damage in FS step
|
||||
|
||||
a_pow = atk.get_power()
|
||||
a_tou = atk.get_toughness()
|
||||
blocker_ids = combat.blockers.get(atk_id, [])
|
||||
|
||||
if blocker_ids:
|
||||
blocker = next((c for bf in sim.battlefields for c in bf if c.id == blocker_ids[0]), None)
|
||||
if blocker is None:
|
||||
# Blocker removed — attacker is still blocked but deals no combat damage to player
|
||||
continue
|
||||
b_pow = blocker.get_power()
|
||||
blocker.damage_marked += a_pow
|
||||
if blocker.damage_marked >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(blocker.id)
|
||||
self._send_to_graveyard(sim, blocker, opp_idx)
|
||||
if events is not None:
|
||||
combat_details.append(f" {blocker.name} dies")
|
||||
atk.damage_marked += b_pow
|
||||
if atk.damage_marked >= a_tou or blocker.has_keyword("deathtouch"):
|
||||
sim.remove_from_battlefield(atk.id)
|
||||
self._send_to_graveyard(sim, atk, pi)
|
||||
if events is not None:
|
||||
combat_details.append(f" {atk.name} dies")
|
||||
# Trample over
|
||||
trample_over = 0
|
||||
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
|
||||
trample_over = a_pow - blocker.get_toughness()
|
||||
attacker_damage.append((atk, trample_over))
|
||||
total_damage += trample_over
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += min(a_pow, blocker.get_toughness()) + trample_over
|
||||
else:
|
||||
# Unblocked
|
||||
atk_dmg = a_pow * 2 if atk.has_keyword("double_strike") else a_pow
|
||||
if atk.has_keyword("lifelink"):
|
||||
p.life += atk_dmg
|
||||
attacker_damage.append((atk, atk_dmg))
|
||||
total_damage += atk_dmg
|
||||
|
||||
# Route each attacker's damage through deal_damage()
|
||||
for atk_card, dmg in attacker_damage:
|
||||
if dmg > 0:
|
||||
self.deal_damage(
|
||||
sim, dmg, opp_idx,
|
||||
source_card=atk_card, source_seat=pi,
|
||||
is_combat=True,
|
||||
)
|
||||
|
||||
if first_strike_only:
|
||||
combat.first_strike_resolved = True
|
||||
|
||||
if events is not None and total_damage > 0:
|
||||
opp = sim.players[opp_idx]
|
||||
total_cmd_damage = sum(
|
||||
dmg for (atk_card, dmg) in attacker_damage
|
||||
if atk_card.is_commander and dmg > 0
|
||||
)
|
||||
cmd_note = f" ({total_cmd_damage} cmdr)" if total_cmd_damage > 0 else ""
|
||||
events.append(
|
||||
f"Dealt {total_damage} combat damage{cmd_note} to {opp.name} (now {opp.life} life)"
|
||||
)
|
||||
events.extend(combat_details)
|
||||
|
||||
|
||||
if opp.life <= 0:
|
||||
opp.eliminated = True
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
Forge Card Loader
|
||||
=================
|
||||
Parse Forge cardsfolder .txt files into structured data for sim enrichment.
|
||||
Lazy-loaded and cached at module level.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from models.state import CFG
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ForgeCardData:
|
||||
"""Structured representation of a single Forge .txt card definition."""
|
||||
name: str = ""
|
||||
mana_cost: str = ""
|
||||
cmc: int = 0
|
||||
type_line: str = ""
|
||||
oracle_text: str = ""
|
||||
pt: str = ""
|
||||
keywords: list[str] = field(default_factory=list)
|
||||
trigger_modes: list[str] = field(default_factory=list)
|
||||
has_replacement_effect: bool = False
|
||||
has_static_ability: bool = False
|
||||
|
||||
|
||||
# -- Module-level lazy cache ---
|
||||
_FORGE_CACHE: dict[str, ForgeCardData] = {}
|
||||
_FORGE_CACHE_LOADED: bool = False
|
||||
|
||||
|
||||
def _get_cards_folder() -> Path:
|
||||
"""Resolve the Forge cardsfolder directory from CFG.forge_dir."""
|
||||
if not CFG.forge_dir:
|
||||
return Path("")
|
||||
return Path(CFG.forge_dir) / "forge-gui" / "res" / "cardsfolder"
|
||||
|
||||
|
||||
def _parse_forge_file(filepath: Path) -> Optional[ForgeCardData]:
|
||||
"""Parse a single Forge .txt card file into ForgeCardData."""
|
||||
try:
|
||||
text = filepath.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
data = ForgeCardData()
|
||||
lines = text.splitlines()
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Name
|
||||
if line.startswith("Name:"):
|
||||
data.name = line.split(":", 1)[1].strip()
|
||||
# ManaCost
|
||||
elif line.startswith("ManaCost:"):
|
||||
raw = line.split(":", 1)[1].strip()
|
||||
data.mana_cost = raw
|
||||
data.cmc = _calc_cmc(raw)
|
||||
# Types
|
||||
elif line.startswith("Types:"):
|
||||
data.type_line = line.split(":", 1)[1].strip()
|
||||
# PT
|
||||
elif line.startswith("PT:"):
|
||||
data.pt = line.split(":", 1)[1].strip()
|
||||
# Oracle
|
||||
elif line.startswith("Oracle:"):
|
||||
data.oracle_text = line.split(":", 1)[1].strip()
|
||||
# K -- Keywords
|
||||
elif line.startswith("K:"):
|
||||
kw = line.split(":", 1)[1].strip()
|
||||
if kw and kw not in data.keywords:
|
||||
data.keywords.append(kw)
|
||||
# T -- Trigger modes (e.g. T:Mode$ ChangesZone)
|
||||
elif line.startswith("T:"):
|
||||
mode_match = re.search(r"Mode\$\s*(\w+)", line)
|
||||
if mode_match:
|
||||
mode = mode_match.group(1)
|
||||
if mode not in data.trigger_modes:
|
||||
data.trigger_modes.append(mode)
|
||||
# R -- Replacement effects
|
||||
elif line.startswith("R:"):
|
||||
data.has_replacement_effect = True
|
||||
# S -- Static abilities
|
||||
elif line.startswith("S:"):
|
||||
data.has_static_ability = True
|
||||
# SVar (secondary -- grab Oracle from SVar if not set)
|
||||
elif line.startswith("SVar:") and not data.oracle_text:
|
||||
if "Oracle:" in line:
|
||||
oracle_part = line.split("Oracle:", 1)[1].strip()
|
||||
data.oracle_text = oracle_part
|
||||
|
||||
return data if data.name else None
|
||||
|
||||
|
||||
def _calc_cmc(mana_cost: str) -> int:
|
||||
"""Calculate CMC from Forge mana cost string like '2 W U'."""
|
||||
total = 0
|
||||
for part in mana_cost.split():
|
||||
part = part.strip().upper()
|
||||
if not part:
|
||||
continue
|
||||
if part.isdigit():
|
||||
total += int(part)
|
||||
elif part == "X":
|
||||
continue
|
||||
else:
|
||||
total += 1
|
||||
return total
|
||||
|
||||
|
||||
def _load_forge_cache() -> None:
|
||||
"""Walk the Forge cardsfolder and cache every card by lowercase name."""
|
||||
global _FORGE_CACHE_LOADED
|
||||
if _FORGE_CACHE_LOADED:
|
||||
return
|
||||
|
||||
cards_folder = _get_cards_folder()
|
||||
if not cards_folder.is_dir():
|
||||
_log.warning("Forge cardsfolder not found at %s", cards_folder)
|
||||
_FORGE_CACHE_LOADED = True
|
||||
return
|
||||
|
||||
count = 0
|
||||
for root, _dirs, files in os.walk(cards_folder):
|
||||
for fname in files:
|
||||
if not fname.endswith(".txt"):
|
||||
continue
|
||||
fpath = Path(root) / fname
|
||||
parsed = _parse_forge_file(fpath)
|
||||
if parsed and parsed.name:
|
||||
_FORGE_CACHE[parsed.name.lower()] = parsed
|
||||
count += 1
|
||||
|
||||
_log.info("Forge card cache loaded: %d cards from %s", count, cards_folder)
|
||||
_FORGE_CACHE_LOADED = True
|
||||
|
||||
|
||||
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())
|
||||
@@ -15,7 +15,7 @@ import json
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.models import Card, CombatState, Phase, Player, SimState
|
||||
from commander_ai_lab.sim.models import Card, Player, SimState
|
||||
|
||||
|
||||
# ── Mana Pool ────────────────────────────────────────────────
|
||||
@@ -95,6 +95,9 @@ class CommanderPlayer:
|
||||
# Whether this player has drawn their card this turn
|
||||
drawn_this_turn: bool = False
|
||||
|
||||
# Commander damage received from each opponent (keyed by seat index)
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# ── Convenience pass-throughs to base Player ─────────────
|
||||
|
||||
@property
|
||||
@@ -133,18 +136,13 @@ class CommanderPlayer:
|
||||
def library(self) -> list[Card]:
|
||||
return self.base.library
|
||||
|
||||
@property
|
||||
def commander_damage_received(self) -> dict[int, int]:
|
||||
"""Delegate to base Player so headless engine writes are visible."""
|
||||
return self.base.commander_damage_received
|
||||
|
||||
def commander_tax(self) -> int:
|
||||
"""Additional mana cost (2 per cast from command zone)."""
|
||||
return self.commander_cast_count * 2
|
||||
|
||||
def is_dead_to_commander_damage(self) -> bool:
|
||||
"""Returns True if any single commander has dealt 21+ damage."""
|
||||
return self.base.is_dead_to_commander_damage()
|
||||
"""Returns True if any single opponent has dealt 21+ commander damage."""
|
||||
return any(v >= 21 for v in self.commander_damage_received.values())
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -196,7 +194,7 @@ class CommanderGameState:
|
||||
# Phase tracking
|
||||
# Phases: untap, upkeep, draw, main1, combat_begin, combat_declare_attackers,
|
||||
# combat_declare_blockers, combat_damage, combat_end, main2, end, cleanup
|
||||
current_phase: str | Phase = Phase.MAIN1
|
||||
current_phase: str = "main1"
|
||||
|
||||
# Turn number (mirrors sim_state.turn)
|
||||
turn: int = 0
|
||||
@@ -229,68 +227,224 @@ class CommanderGameState:
|
||||
def stack_is_empty(self) -> bool:
|
||||
return len(self.stack) == 0
|
||||
|
||||
# Bug 9 fix: stub get_legal_moves() and apply_move() so turn_manager
|
||||
# doesn't crash. These delegate to the rules engine once implemented.
|
||||
# ── Legal Moves & Apply (BUG-02 fix) ─────────────────────
|
||||
# These replace the former pass-only stubs so that the turn manager
|
||||
# can drive real game actions through the CommanderGameState.
|
||||
|
||||
def get_legal_moves(self, seat: int) -> list[dict]:
|
||||
"""Return legal moves for a seat.
|
||||
"""Return legal moves for a seat based on mana, phase, and card type.
|
||||
|
||||
During DECLARE_BLOCKERS phase, emits block moves for defending
|
||||
creatures against each attacker in sim_state.combat.
|
||||
Otherwise returns pass-only (stub for remaining rules engine).
|
||||
Move categories:
|
||||
- pass_priority: always available
|
||||
- play_land: if main phase, land drop not used, and land in hand
|
||||
- cast_spell: if main phase and castable cards in hand
|
||||
- attack: if declare_attackers phase and creatures can attack
|
||||
- instant/activate_ability: if holding-priority phases
|
||||
"""
|
||||
moves: list[dict] = [{"id": 0, "category": "pass_priority", "description": "Pass"}]
|
||||
combat = self.sim_state.combat
|
||||
moves: list[dict] = []
|
||||
move_id = 0
|
||||
|
||||
# Emit block moves during declare_blockers phase
|
||||
if (
|
||||
combat
|
||||
and combat.active
|
||||
and self.current_phase in (Phase.DECLARE_BLOCKERS, "declare_blockers")
|
||||
and seat != self.active_player_seat
|
||||
):
|
||||
move_id = 1000
|
||||
my_creatures = [
|
||||
c for c in self.sim_state.get_battlefield(seat)
|
||||
if c.is_creature() and not c.tapped
|
||||
]
|
||||
for atk_id in combat.attackers:
|
||||
atk = next(
|
||||
(c for bf in self.sim_state.battlefields for c in bf if c.id == atk_id),
|
||||
None,
|
||||
)
|
||||
if atk is None:
|
||||
if seat >= len(self.commander_players):
|
||||
return [{"id": 0, "category": "pass_priority", "description": "Pass"}]
|
||||
|
||||
cp = self.commander_players[seat]
|
||||
player = cp.base
|
||||
bf = self.sim_state.get_battlefield(seat)
|
||||
|
||||
# Available mana = untapped lands + untapped mana-producing artifacts
|
||||
available_mana = sum(
|
||||
1 for c in bf
|
||||
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature()))
|
||||
)
|
||||
|
||||
# ── Main phase actions ──
|
||||
if self.current_phase in ("main1", "main2"):
|
||||
# Play land (one per turn)
|
||||
if not self.land_drop_used:
|
||||
for i, card in enumerate(player.hand):
|
||||
if card.is_land():
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "play_land",
|
||||
"description": f"Play land: {card.name}",
|
||||
"card_index": i,
|
||||
"card_name": card.name,
|
||||
})
|
||||
move_id += 1
|
||||
break # only one land drop offered
|
||||
|
||||
# Cast spells from hand (sorcery-speed: creatures, sorceries,
|
||||
# artifacts, enchantments, planeswalkers)
|
||||
for i, card in enumerate(player.hand):
|
||||
if card.is_land():
|
||||
continue
|
||||
for blocker in my_creatures:
|
||||
cmc = card.cmc or 0
|
||||
if cmc <= available_mana:
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "block",
|
||||
"blocker_id": blocker.id,
|
||||
"attacker_id": atk_id,
|
||||
"description": f"Block {atk.name} with {blocker.name}",
|
||||
"category": "cast_spell",
|
||||
"description": f"Cast {card.name} (CMC {cmc})",
|
||||
"card_index": i,
|
||||
"card_name": card.name,
|
||||
})
|
||||
move_id += 1
|
||||
|
||||
return moves
|
||||
def apply_move(self, seat: int, move_id: int) -> None:
|
||||
"""Apply a chosen move.
|
||||
# Cast commander from command zone
|
||||
for i, cmd in enumerate(cp.commander_zone):
|
||||
tax = player.commander_tax.get(cmd.name, 0)
|
||||
total = (cmd.cmc or 0) + tax
|
||||
if total <= available_mana:
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "cast_spell",
|
||||
"description": f"Cast commander {cmd.name} (cost {total}, tax {tax})",
|
||||
"card_name": cmd.name,
|
||||
"from_zone": "command",
|
||||
"cmd_index": i,
|
||||
})
|
||||
move_id += 1
|
||||
|
||||
Handles 'block' moves by updating sim_state.combat.blockers.
|
||||
Other categories are no-op stubs until the rules engine is wired.
|
||||
# ── Combat: declare attackers ──
|
||||
if self.current_phase == "declare_attackers":
|
||||
attackable_creatures = [
|
||||
c for c in bf
|
||||
if c.can_attack_or_block()
|
||||
and not c.tapped
|
||||
and (c.turn_played < self.turn or c.has_keyword("haste"))
|
||||
]
|
||||
if attackable_creatures:
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "attack",
|
||||
"description": f"Attack with all ({len(attackable_creatures)} creatures)",
|
||||
"attack_mode": "all",
|
||||
})
|
||||
move_id += 1
|
||||
|
||||
# ── Instant-speed responses ──
|
||||
if self.current_phase in ("upkeep", "begin_combat", "end_combat",
|
||||
"end_step", "declare_blockers"):
|
||||
for i, card in enumerate(player.hand):
|
||||
if card.is_instant():
|
||||
cmc = card.cmc or 0
|
||||
if cmc <= available_mana:
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "instant",
|
||||
"description": f"Cast {card.name} (instant, CMC {cmc})",
|
||||
"card_index": i,
|
||||
"card_name": card.name,
|
||||
})
|
||||
move_id += 1
|
||||
|
||||
# Always allow passing priority
|
||||
moves.append({
|
||||
"id": move_id,
|
||||
"category": "pass_priority",
|
||||
"description": "Pass",
|
||||
})
|
||||
|
||||
return moves
|
||||
|
||||
def apply_move(self, seat: int, move_id: int) -> None:
|
||||
"""Apply a chosen move to the game state.
|
||||
|
||||
Looks up the move by id in the current legal moves list and
|
||||
executes the corresponding game action.
|
||||
"""
|
||||
# Find the move dict
|
||||
all_moves = self.get_legal_moves(seat)
|
||||
move = next((m for m in all_moves if m["id"] == move_id), None)
|
||||
legal = self.get_legal_moves(seat)
|
||||
move = None
|
||||
for m in legal:
|
||||
if m["id"] == move_id:
|
||||
move = m
|
||||
break
|
||||
if move is None:
|
||||
return # invalid move id — no-op
|
||||
|
||||
cat = move["category"]
|
||||
if cat == "pass_priority":
|
||||
return
|
||||
|
||||
if move.get("category") == "block":
|
||||
combat = self.sim_state.combat
|
||||
if combat and combat.active:
|
||||
atk_id = move["attacker_id"]
|
||||
blocker_id = move["blocker_id"]
|
||||
if atk_id not in combat.blockers:
|
||||
combat.blockers[atk_id] = []
|
||||
combat.blockers[atk_id].append(blocker_id)
|
||||
if seat >= len(self.commander_players):
|
||||
return
|
||||
cp = self.commander_players[seat]
|
||||
player = cp.base
|
||||
bf = self.sim_state.get_battlefield(seat)
|
||||
|
||||
if cat == "play_land":
|
||||
idx = move.get("card_index", -1)
|
||||
if 0 <= idx < len(player.hand):
|
||||
card = player.hand.pop(idx)
|
||||
card.owner_id = seat
|
||||
card.tapped = False
|
||||
card.id = self.sim_state.next_card_id
|
||||
self.sim_state.next_card_id += 1
|
||||
self.sim_state.add_to_battlefield(seat, card)
|
||||
self.land_drop_used = True
|
||||
cp.lands_played_this_turn += 1
|
||||
|
||||
elif cat == "cast_spell":
|
||||
from_zone = move.get("from_zone", "hand")
|
||||
if from_zone == "command":
|
||||
cmd_idx = move.get("cmd_index", 0)
|
||||
if cmd_idx < len(cp.commander_zone):
|
||||
card = cp.commander_zone.pop(cmd_idx)
|
||||
tax = player.commander_tax.get(card.name, 0)
|
||||
total_cost = (card.cmc or 0) + tax
|
||||
card.owner_id = seat
|
||||
card.tapped = False
|
||||
card.id = self.sim_state.next_card_id
|
||||
self.sim_state.next_card_id += 1
|
||||
card.turn_played = self.turn
|
||||
self._tap_mana_sources(seat, total_cost)
|
||||
self.sim_state.add_to_battlefield(seat, card)
|
||||
player.commander_tax[card.name] = tax + 2
|
||||
else:
|
||||
idx = move.get("card_index", -1)
|
||||
if 0 <= idx < len(player.hand):
|
||||
card = player.hand.pop(idx)
|
||||
card.owner_id = seat
|
||||
card.tapped = False
|
||||
card.id = self.sim_state.next_card_id
|
||||
self.sim_state.next_card_id += 1
|
||||
card.turn_played = self.turn
|
||||
self._tap_mana_sources(seat, card.cmc or 0)
|
||||
if card.is_permanent():
|
||||
self.sim_state.add_to_battlefield(seat, card)
|
||||
else:
|
||||
# Instants/sorceries go to graveyard after resolution
|
||||
player.graveyard.append(card)
|
||||
|
||||
elif cat == "instant":
|
||||
idx = move.get("card_index", -1)
|
||||
if 0 <= idx < len(player.hand):
|
||||
card = player.hand.pop(idx)
|
||||
card.owner_id = seat
|
||||
card.id = self.sim_state.next_card_id
|
||||
self.sim_state.next_card_id += 1
|
||||
self._tap_mana_sources(seat, card.cmc or 0)
|
||||
# Instants resolve and go to graveyard
|
||||
player.graveyard.append(card)
|
||||
|
||||
elif cat == "attack":
|
||||
# Mark all eligible creatures as attacking (tapped)
|
||||
for c in bf:
|
||||
if (c.can_attack_or_block()
|
||||
and not c.tapped
|
||||
and (c.turn_played < self.turn or c.has_keyword("haste"))):
|
||||
c.tapped = True
|
||||
|
||||
def _tap_mana_sources(self, seat: int, amount: int) -> None:
|
||||
"""Tap lands and mana rocks to pay a cost."""
|
||||
bf = self.sim_state.get_battlefield(seat)
|
||||
remaining = amount
|
||||
for c in bf:
|
||||
if remaining <= 0:
|
||||
break
|
||||
if not c.tapped and (c.is_land() or (c.is_ramp and not c.is_creature())):
|
||||
c.tapped = True
|
||||
remaining -= 1
|
||||
|
||||
def living_players(self) -> list[CommanderPlayer]:
|
||||
return [p for p in self.commander_players if not p.eliminated]
|
||||
|
||||
@@ -301,25 +455,13 @@ class CommanderGameState:
|
||||
return p.commander_damage_received.get(from_seat, 0)
|
||||
return 0
|
||||
|
||||
def deal_commander_damage(
|
||||
self, from_seat: int, to_seat: int, amount: int,
|
||||
commander_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record commander damage and reduce life total.
|
||||
|
||||
If *commander_name* is provided the per-card breakdown is also
|
||||
updated (needed for partner-correct 21-damage checks).
|
||||
"""
|
||||
def deal_commander_damage(self, from_seat: int, to_seat: int, amount: int) -> None:
|
||||
"""Record commander damage and reduce life total."""
|
||||
if to_seat < len(self.commander_players):
|
||||
p = self.commander_players[to_seat]
|
||||
p.commander_damage_received[from_seat] = (
|
||||
p.commander_damage_received.get(from_seat, 0) + amount
|
||||
)
|
||||
if commander_name is not None:
|
||||
key = (from_seat, commander_name)
|
||||
p.base.commander_damage_by_card[key] = (
|
||||
p.base.commander_damage_by_card.get(key, 0) + amount
|
||||
)
|
||||
p.life -= amount
|
||||
|
||||
# ── Serialization ────────────────────────────────────────
|
||||
|
||||
@@ -11,73 +11,12 @@ SimState.battlefields is per-player: battlefields[seat_index] holds that player'
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import enum
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# ── Turn Phases ──────────────────────────────────────────────
|
||||
|
||||
class Phase(str, enum.Enum):
|
||||
"""MTG turn phases in order.
|
||||
|
||||
Using ``str`` as a mixin allows direct comparison with plain strings
|
||||
and clean JSON serialization (``phase.value`` is a string).
|
||||
"""
|
||||
|
||||
UNTAP = "untap"
|
||||
UPKEEP = "upkeep"
|
||||
DRAW = "draw"
|
||||
MAIN1 = "main1"
|
||||
BEGIN_COMBAT = "begin_combat"
|
||||
DECLARE_ATTACKERS = "declare_attackers"
|
||||
DECLARE_BLOCKERS = "declare_blockers"
|
||||
COMBAT_DAMAGE = "combat_damage"
|
||||
END_COMBAT = "end_combat"
|
||||
MAIN2 = "main2"
|
||||
END_STEP = "end_step"
|
||||
CLEANUP = "cleanup"
|
||||
|
||||
|
||||
# Ordered tuple for iteration
|
||||
PHASE_ORDER: tuple[Phase, ...] = tuple(Phase)
|
||||
|
||||
# Phases where sorcery-speed actions (land drops, casting creatures) are legal
|
||||
SORCERY_PHASES: frozenset[Phase] = frozenset({Phase.MAIN1, Phase.MAIN2})
|
||||
|
||||
|
||||
# ── Combat State ───────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class CombatState:
|
||||
"""Transient combat state shared across combat sub-phases.
|
||||
|
||||
Created at BEGIN_COMBAT, cleared to None at END_COMBAT.
|
||||
Allows priority windows between attacker declaration, blocker
|
||||
declaration, and damage resolution (Issue #86 Item 1).
|
||||
"""
|
||||
|
||||
# Seat index of the defending player (multiplayer: chosen target)
|
||||
defending_seat: int = -1
|
||||
|
||||
# attacker card_id -> defending seat
|
||||
attackers: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# attacker card_id -> list of blocker card_ids
|
||||
blockers: dict[int, list[int]] = field(default_factory=dict)
|
||||
|
||||
# Damage routed to the defending player per-attacker (for unblocked / trample)
|
||||
player_damage: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# Whether the first-strike damage sub-step has already resolved
|
||||
first_strike_resolved: bool = False
|
||||
|
||||
# Whether combat is currently active
|
||||
active: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Card:
|
||||
"""A single card in the simulation."""
|
||||
@@ -98,15 +37,6 @@ class Card:
|
||||
is_removal: bool = False
|
||||
is_board_wipe: bool = False
|
||||
is_commander: bool = False
|
||||
is_direct_damage: bool = False
|
||||
direct_damage_amount: int = 0
|
||||
|
||||
# Forge enrichment fields (set by forge_card_loader)
|
||||
forge_enriched: bool = False
|
||||
forge_keywords: list[str] = field(default_factory=list)
|
||||
forge_trigger_modes: list[str] = field(default_factory=list)
|
||||
has_replacement_effect: bool = False
|
||||
has_static_ability: bool = False
|
||||
|
||||
# Runtime fields (set during game)
|
||||
id: int = 0
|
||||
@@ -160,6 +90,38 @@ class Card:
|
||||
def is_creature(self) -> bool:
|
||||
return bool(self.type_line and "creature" in self.type_line.lower())
|
||||
|
||||
def is_instant(self) -> bool:
|
||||
"""Check if this card is an Instant."""
|
||||
return bool(self.type_line and "instant" in self.type_line.lower())
|
||||
|
||||
def is_sorcery(self) -> bool:
|
||||
"""Check if this card is a Sorcery."""
|
||||
return bool(self.type_line and "sorcery" in self.type_line.lower())
|
||||
|
||||
def is_permanent(self) -> bool:
|
||||
"""Check if this card is a permanent type (not instant/sorcery).
|
||||
|
||||
Permanents are creatures, artifacts, enchantments, planeswalkers, lands,
|
||||
and battles. Instants and sorceries are non-permanent spells.
|
||||
Cards with unknown type are treated as permanents to avoid being
|
||||
incorrectly discarded.
|
||||
"""
|
||||
if not self.type_line:
|
||||
return True # no type info — keep on battlefield to be safe
|
||||
tl = self.type_line.lower()
|
||||
return "instant" not in tl and "sorcery" not in tl
|
||||
|
||||
def can_attack_or_block(self) -> bool:
|
||||
"""Check if this card can participate in combat.
|
||||
|
||||
Per MTG rules, only creatures can attack or block.
|
||||
Cards with unknown/missing type, artifacts, enchantments, etc.
|
||||
are NOT eligible for combat unless their type_line includes 'Creature'.
|
||||
"""
|
||||
if not self.type_line:
|
||||
return False
|
||||
return "creature" in self.type_line.lower()
|
||||
|
||||
def clone(self) -> "Card":
|
||||
"""Return a deep copy of this card."""
|
||||
return copy.deepcopy(self)
|
||||
@@ -170,8 +132,6 @@ class PlayerStats:
|
||||
"""Per-game statistics tracked for a player."""
|
||||
|
||||
cards_drawn: int = 7 # starting hand
|
||||
mulligans: int = 0
|
||||
opening_hand_lands: int = 0
|
||||
lands_played: int = 0
|
||||
spells_cast: int = 0
|
||||
creatures_played: int = 0
|
||||
@@ -187,8 +147,6 @@ class PlayerStats:
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"cardsDrawn": self.cards_drawn,
|
||||
"mulligans": self.mulligans,
|
||||
"openingHandLands": self.opening_hand_lands,
|
||||
"landsPlayed": self.lands_played,
|
||||
"spellsCast": self.spells_cast,
|
||||
"creaturesPlayed": self.creatures_played,
|
||||
@@ -226,29 +184,8 @@ class Player:
|
||||
# p.commander_tax[name] = tax + 2
|
||||
commander_tax: dict = field(default_factory=dict)
|
||||
|
||||
# Commander damage received from each opponent, keyed by seat index.
|
||||
# This is the aggregate per-seat total (used by GUI matrix and prompt
|
||||
# builder). For games where a player controls partner commanders
|
||||
# the per-card breakdown lives in commander_damage_by_card.
|
||||
commander_damage_received: dict[int, int] = field(default_factory=dict)
|
||||
|
||||
# Per-commander breakdown: {(seat, card_name): damage}.
|
||||
# In MTG rules, lethal commander damage (21+) is tracked per individual
|
||||
# commander card, not per player. This dict captures that.
|
||||
commander_damage_by_card: dict[tuple[int, str], int] = field(default_factory=dict)
|
||||
|
||||
stats: PlayerStats = field(default_factory=PlayerStats)
|
||||
|
||||
def is_dead_to_commander_damage(self) -> bool:
|
||||
"""Returns True if any single commander has dealt 21+ damage.
|
||||
|
||||
Checks per-card tracking first (partner-correct); falls back to
|
||||
per-seat totals for backward compatibility with older game states.
|
||||
"""
|
||||
if self.commander_damage_by_card:
|
||||
return any(v >= 21 for v in self.commander_damage_by_card.values())
|
||||
return any(v >= 21 for v in self.commander_damage_received.values())
|
||||
|
||||
|
||||
@dataclass
|
||||
class SimState:
|
||||
@@ -264,11 +201,6 @@ class SimState:
|
||||
turn: int = 0
|
||||
max_turns: int = 25
|
||||
next_card_id: int = 90000
|
||||
current_phase: Phase = Phase.MAIN1
|
||||
active_player_index: int = 0
|
||||
|
||||
# Combat state (transient, set during combat phases)
|
||||
combat: Optional[CombatState] = None
|
||||
|
||||
# ── Battlefield helpers ──────────────────────────────────
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import re
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.models import Card
|
||||
# Forge enrichment (sibling module inside sim/)
|
||||
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card, ForgeCardData
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -141,47 +139,6 @@ _KNOWN_CARDS: list[tuple[re.Pattern, dict]] = [
|
||||
]
|
||||
|
||||
|
||||
|
||||
def _apply_forge_data(card: Card, forge_data: ForgeCardData) -> None:
|
||||
"""
|
||||
Apply Forge card data onto a Card, merging keywords into both fields.
|
||||
|
||||
ISSUE 3 FIX: Writes to both card.keywords AND card.forge_keywords
|
||||
so existing score_card() checks like `if "flying" in kw_list` work.
|
||||
|
||||
ISSUE 4 FIX: Sets card.oracle_text BEFORE returning, so the caller
|
||||
can safely call _apply_oracle_flags(card) afterward.
|
||||
"""
|
||||
# Forge-specific tracking fields
|
||||
card.forge_keywords = list(forge_data.keywords)
|
||||
card.forge_trigger_modes = list(forge_data.trigger_modes)
|
||||
card.has_replacement_effect = forge_data.has_replacement_effect
|
||||
card.has_static_ability = forge_data.has_static_ability
|
||||
|
||||
# ISSUE 3: Merge keywords into card.keywords (set union, no dupes)
|
||||
existing = {k.lower() for k in (card.keywords or [])}
|
||||
for kw in forge_data.keywords:
|
||||
if kw.lower() not in existing:
|
||||
card.keywords.append(kw)
|
||||
existing.add(kw.lower())
|
||||
|
||||
# Overwrite type/cost/pt only if Forge has data AND card doesn't
|
||||
if forge_data.type_line and (not card.type_line or len(card.type_line) < 3):
|
||||
card.type_line = forge_data.type_line
|
||||
if forge_data.mana_cost and not card.mana_cost:
|
||||
card.mana_cost = forge_data.mana_cost
|
||||
card.cmc = forge_data.cmc
|
||||
if forge_data.pt and not card.pt:
|
||||
card.pt = forge_data.pt
|
||||
parts = forge_data.pt.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
card.power = parts[0].strip()
|
||||
card.toughness = parts[1].strip()
|
||||
|
||||
# ISSUE 4: Set oracle_text so _apply_oracle_flags sees Forge text
|
||||
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.
|
||||
@@ -192,19 +149,6 @@ def enrich_card(card: Card) -> Card:
|
||||
|
||||
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
|
||||
|
||||
# Try Forge enrichment first for cards without Scryfall data
|
||||
if not has_real_data:
|
||||
forge_data = lookup_forge_card(card.name)
|
||||
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
|
||||
has_real_data = card.cmc > 0 or (card.type_line and len(card.type_line) > 5)
|
||||
if has_real_data:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
# Otherwise fall through so known-cards / default heuristics can fill gaps
|
||||
|
||||
if not has_real_data:
|
||||
# Basic lands
|
||||
if name_lower in _BASIC_LAND_COLORS:
|
||||
@@ -223,14 +167,16 @@ def enrich_card(card: Card) -> Card:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
|
||||
# Default: treat as creature with deterministic CMC based on
|
||||
# card name hash (fixes #32 — no more random.randint jitter
|
||||
# between games for the same card).
|
||||
# Default: unknown cards get deterministic CMC based on card
|
||||
# name hash (fixes #32 — no more random.randint jitter).
|
||||
# Fix #82: do NOT default to "Creature". Unknown cards are
|
||||
# classified as "Unknown" and cannot attack/block until their
|
||||
# real type is confirmed via Scryfall data.
|
||||
if not card.type_line or len(card.type_line) < 3:
|
||||
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
|
||||
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
|
||||
estimated_pow = max(1, estimated_cmc - 1)
|
||||
card.type_line = "Creature"
|
||||
card.type_line = "Unknown"
|
||||
card.cmc = estimated_cmc
|
||||
card.pt = f"{estimated_pow}/{estimated_pow}"
|
||||
card.power = str(estimated_pow)
|
||||
@@ -264,10 +210,6 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
]):
|
||||
card.is_removal = True
|
||||
|
||||
# Direct-damage spells (burn): "deals N damage to target/any target"
|
||||
if not card.is_direct_damage:
|
||||
_detect_direct_damage(card, oracle)
|
||||
|
||||
# Bug 17 fix: evaluate ramp independently — cards can be both removal and ramp
|
||||
# (e.g. Nature's Claim, Beast Within, Abrupt Decay)
|
||||
if not card.is_ramp:
|
||||
@@ -284,64 +226,6 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
card.is_ramp = True
|
||||
|
||||
|
||||
# ── 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(
|
||||
r"deals?\s+(\d+)\s+damage\s+to\s+(?:target|any\s+target)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Well-known burn spells with fixed damage amounts for reliable detection
|
||||
_KNOWN_BURN: dict[str, int] = {
|
||||
"lightning bolt": 3,
|
||||
"shock": 2,
|
||||
"lightning strike": 3,
|
||||
"lava spike": 3,
|
||||
"rift bolt": 3,
|
||||
"searing blaze": 3,
|
||||
"chain lightning": 3,
|
||||
"flame rift": 4,
|
||||
"exquisite firecraft": 4,
|
||||
"lightning helix": 3,
|
||||
"warleader's helix": 4,
|
||||
"boros charm": 4,
|
||||
"searing spear": 3,
|
||||
"incinerate": 3,
|
||||
"magma jet": 2,
|
||||
"searing blood": 2,
|
||||
"firebolt": 2,
|
||||
"galvanic blast": 2,
|
||||
"skullcrack": 3,
|
||||
"flames of the firebrand": 3,
|
||||
"stoke the flames": 4,
|
||||
}
|
||||
|
||||
|
||||
def _detect_direct_damage(card: Card, oracle: str) -> None:
|
||||
"""Detect direct-damage (burn) spells and set card flags."""
|
||||
# Check known-burn table first (most reliable)
|
||||
name_lower = (card.name or "").lower().strip()
|
||||
if name_lower in _KNOWN_BURN:
|
||||
card.is_direct_damage = True
|
||||
card.direct_damage_amount = _KNOWN_BURN[name_lower]
|
||||
return
|
||||
|
||||
# Fall back to regex detection from oracle text
|
||||
if not oracle:
|
||||
return
|
||||
# Skip board wipes and each-creature effects
|
||||
if card.is_board_wipe:
|
||||
return
|
||||
if "each creature" in oracle or "all creature" in oracle:
|
||||
return
|
||||
|
||||
m = _DIRECT_DAMAGE_RE.search(oracle)
|
||||
if m:
|
||||
card.is_direct_damage = True
|
||||
card.direct_damage_amount = int(m.group(1))
|
||||
|
||||
|
||||
# ── Multi-opponent scaling factors ────────────────────────────
|
||||
# Keywords / effects that become more valuable in larger pods.
|
||||
# Each entry maps a weight key → the extra bonus *per additional
|
||||
@@ -436,21 +320,11 @@ def score_card(
|
||||
score += w.get("kw_cascade", 3.0)
|
||||
if "defender" in text or "defender" in kw_list:
|
||||
score += w.get("kw_defender", -2.0)
|
||||
# NEW
|
||||
_has_etb = False
|
||||
if card.forge_trigger_modes and "ChangesZone" in card.forge_trigger_modes:
|
||||
_has_etb = True
|
||||
elif "when" in text and "enters" in text:
|
||||
_has_etb = True
|
||||
if _has_etb:
|
||||
|
||||
# ── Triggered ability bonuses ──
|
||||
if "when" in text and "enters" in text:
|
||||
score += w.get("trig_etb", 2.0) + _MULTI_OPP_SCALING.get("trig_etb", 0.0) * extra_opps
|
||||
# NEW
|
||||
_has_attack_trig = False
|
||||
if card.forge_trigger_modes and "Attacks" in card.forge_trigger_modes:
|
||||
_has_attack_trig = True
|
||||
elif "whenever" in text and "attack" in text:
|
||||
_has_attack_trig = True
|
||||
if _has_attack_trig:
|
||||
if "whenever" in text and "attack" in text:
|
||||
score += w.get("trig_attack", 1.5)
|
||||
if "when" in text and "dies" in text:
|
||||
score += w.get("trig_dies", 1.0) + _MULTI_OPP_SCALING.get("trig_dies", 0.0) * extra_opps
|
||||
|
||||
@@ -42,8 +42,6 @@ class StateLogger:
|
||||
before writing the new one (FIFO rotation).
|
||||
"""
|
||||
|
||||
_counter: int = 0 # monotonic counter to guarantee unique filenames
|
||||
|
||||
def __init__(self, log_dir: Optional[str | Path] = None) -> None:
|
||||
self.log_dir = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
|
||||
self.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -75,9 +73,8 @@ class StateLogger:
|
||||
"""
|
||||
self._rotate()
|
||||
|
||||
StateLogger._counter += 1
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
||||
filename = f"snapshot_{timestamp}_{StateLogger._counter}_t{game_state.turn}_s{seat}.json"
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"snapshot_{timestamp}_t{game_state.turn}_s{seat}.json"
|
||||
filepath = self.log_dir / filename
|
||||
|
||||
snapshot = {
|
||||
|
||||
@@ -46,29 +46,38 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Awaitable, Callable, Optional, Set
|
||||
from commander_ai_lab.sim.models import Phase, PHASE_ORDER, SORCERY_PHASES, CombatState
|
||||
|
||||
from commander_ai_lab.sim.game_state import CommanderGameState, CommanderPlayer
|
||||
from commander_ai_lab.sim.ai_opponent import AIOpponent
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats, ThreatScore
|
||||
|
||||
logger = logging.getLogger("turn_manager")
|
||||
|
||||
# Re-export for backward compatibility; canonical source is models.py
|
||||
PHASES = [p.value for p in PHASE_ORDER]
|
||||
# Commander turn phases in order
|
||||
PHASES = [
|
||||
"untap",
|
||||
"upkeep",
|
||||
"draw",
|
||||
"main1",
|
||||
"begin_combat",
|
||||
"declare_attackers",
|
||||
"declare_blockers",
|
||||
"combat_damage",
|
||||
"end_combat",
|
||||
"main2",
|
||||
"end_step",
|
||||
"cleanup",
|
||||
]
|
||||
|
||||
# Phases where a player can take an action (play land, cast spell, etc.)
|
||||
ACTION_PHASES = {p.value for p in SORCERY_PHASES}
|
||||
ACTION_PHASES = {"main1", "main2"}
|
||||
# Phases where instant-speed responses can happen
|
||||
RESPONSE_PHASES = {
|
||||
Phase.UPKEEP.value,
|
||||
Phase.END_STEP.value,
|
||||
}
|
||||
RESPONSE_PHASES = {"upkeep", "begin_combat", "end_combat", "end_step"}
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -89,7 +98,7 @@ class GameEvent:
|
||||
class TurnManagerConfig:
|
||||
"""Tunable parameters for the turn manager."""
|
||||
max_turns: int = 40
|
||||
ai_decision_timeout: float = float(os.environ.get("BRAIN_TIMEOUT", "300.0")) # seconds before fallback
|
||||
ai_decision_timeout: float = 30.0 # seconds before fallback
|
||||
ai_narration_enabled: bool = True
|
||||
ai_decision_delay: float = 0.0 # cosmetic pause (set > 0 for UI readability)
|
||||
priority_pass_limit: int = 8 # max APNAP passes per phase before auto-advance
|
||||
@@ -353,13 +362,7 @@ class CommanderTurnManager:
|
||||
await self._apnap_priority_window(active_seat, turn_num, phase)
|
||||
|
||||
async def _phase_combat(self, active_seat: int, turn_num: int) -> None:
|
||||
"""Full combat sequence with priority windows at each sub-phase (Issue #86)."""
|
||||
sim = self.gs.sim_state
|
||||
|
||||
# 1. Beginning of combat — priority window BEFORE attackers chosen
|
||||
await self._apnap_priority_window(active_seat, turn_num, "begin_combat")
|
||||
|
||||
# 2. Declare attackers
|
||||
"""Declare attackers phase."""
|
||||
legal_moves = self.get_legal_moves(active_seat)
|
||||
attack_moves = [m for m in legal_moves if m.get("category") == "attack"]
|
||||
if not attack_moves:
|
||||
@@ -388,44 +391,9 @@ class CommanderTurnManager:
|
||||
narration=narration,
|
||||
))
|
||||
|
||||
# 3. After-attackers priority window
|
||||
await self._apnap_priority_window(active_seat, turn_num, "declare_attackers")
|
||||
|
||||
# 4. Declare blockers — each defending player in APNAP order
|
||||
for defender_seat in [s for s in range(len(self.gs.players))
|
||||
if s != active_seat
|
||||
and not self.gs.players[s].eliminated]:
|
||||
block_moves = [m for m in self.get_legal_moves(defender_seat)
|
||||
if m.get("category") == "block"]
|
||||
if block_moves:
|
||||
block_id = await self._get_action(defender_seat, block_moves, turn_num)
|
||||
if block_id is not None:
|
||||
self.gs.apply_move(defender_seat, block_id)
|
||||
|
||||
# 5. After-blockers priority window
|
||||
# Defending players get priority to declare blockers
|
||||
await self._apnap_priority_window(active_seat, turn_num, "declare_blockers")
|
||||
|
||||
# 6. First-strike damage sub-step (if applicable)
|
||||
if self._has_first_strikers():
|
||||
# Resolve first-strike damage via engine split method
|
||||
if sim.combat and sim.combat.active:
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
engine = GameEngine()
|
||||
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=True)
|
||||
await self._apnap_priority_window(active_seat, turn_num, "first_strike_damage")
|
||||
|
||||
# 7. Normal combat damage
|
||||
if sim.combat and sim.combat.active:
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
engine = GameEngine()
|
||||
engine.resolve_combat_damage(sim, active_seat, sim.combat, first_strike_only=False)
|
||||
|
||||
# 8. End of combat priority window
|
||||
await self._apnap_priority_window(active_seat, turn_num, "end_combat")
|
||||
|
||||
# 9. Clear combat state
|
||||
sim.combat = None
|
||||
|
||||
async def _phase_cleanup(self, active_seat: int) -> None:
|
||||
"""Discard to hand size (7), remove end-of-turn effects."""
|
||||
player = self.gs.players[active_seat]
|
||||
@@ -438,22 +406,6 @@ class CommanderTurnManager:
|
||||
player.name, discarded.name,
|
||||
)
|
||||
|
||||
def _has_first_strikers(self) -> bool:
|
||||
"""Check if any creature in active combat has first_strike or double_strike."""
|
||||
sim = self.gs.sim_state
|
||||
combat = sim.combat
|
||||
if not combat or not combat.active:
|
||||
return False
|
||||
all_combat_ids = (
|
||||
list(combat.attackers.keys()) +
|
||||
[bid for blist in combat.blockers.values() for bid in blist]
|
||||
)
|
||||
for card_id in all_combat_ids:
|
||||
card = next((c for bf in sim.battlefields for c in bf if c.id == card_id), None)
|
||||
if card and (card.has_keyword("first_strike") or card.has_keyword("double_strike")):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Priority Passing (APNAP) ──────────────────────────────────────────────
|
||||
|
||||
async def _apnap_priority_window(
|
||||
|
||||
@@ -50,7 +50,7 @@ class ValidatorConfig:
|
||||
|
||||
Example -- LM Studio::
|
||||
cfg = ValidatorConfig(
|
||||
api_base="http://localhost:1234",
|
||||
api_base="http://192.168.0.240:1234",
|
||||
model="deepseek-r1-distill-qwen-14b",
|
||||
)
|
||||
"""
|
||||
@@ -58,7 +58,7 @@ class ValidatorConfig:
|
||||
model: str = "deepseek-r1:14b"
|
||||
temperature: float = 0.0
|
||||
max_tokens: int = 2048
|
||||
request_timeout: float = 300.0
|
||||
request_timeout: float = 60.0
|
||||
enabled: bool = True
|
||||
|
||||
@classmethod
|
||||
@@ -70,7 +70,7 @@ class ValidatorConfig:
|
||||
model=os.environ.get("VALIDATOR_MODEL", "deepseek-r1:14b"),
|
||||
temperature=float(os.environ.get("VALIDATOR_TEMPERATURE", "0.0")),
|
||||
max_tokens=int(os.environ.get("VALIDATOR_MAX_TOKENS", "2048")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "300.0")),
|
||||
request_timeout=float(os.environ.get("VALIDATOR_TIMEOUT", "60.0")),
|
||||
enabled=(enabled_str == "true"),
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from fastapi.staticfiles import StaticFiles
|
||||
from commander_ai_lab.web.routers.api import router as api_router
|
||||
from commander_ai_lab.web.routers.ws import router as ws_router
|
||||
from commander_ai_lab.web.routers.art import router as art_router
|
||||
from commander_ai_lab.web.routers.lobby import router as lobby_router, ws_router as lobby_ws_router
|
||||
|
||||
# Prefer the React build output; fall back to legacy static dir
|
||||
_FRONTEND_DIST = Path(__file__).parents[3] / "frontend" / "dist"
|
||||
@@ -67,8 +66,6 @@ def create_app(
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
app.include_router(ws_router) # /ws/game/{game_id}
|
||||
app.include_router(art_router, prefix="/api/v1")
|
||||
app.include_router(lobby_router) # /api/v1/lobby/...
|
||||
app.include_router(lobby_ws_router) # /ws/lobby/{room_id}
|
||||
|
||||
# Static files (web client) — prefer React build, fall back to legacy
|
||||
sd = static_dir or (_FRONTEND_DIST if _FRONTEND_DIST.exists() else _STATIC_DIR)
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
package commanderailab.ai;
|
||||
|
||||
import commanderailab.ml.LearnedPolicy;
|
||||
import commanderailab.ml.MacroActionExecutor;
|
||||
import commanderailab.ml.PolicyServerClient;
|
||||
import commanderailab.schema.DecisionSnapshot;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
|
||||
/**
|
||||
* PolicyClient — Phase 2 bridge between Forge decision points and the
|
||||
* Python policy server for live online training.
|
||||
*
|
||||
* This class extends the existing LearnedPolicy/PolicyServerClient
|
||||
* infrastructure with two key Phase 2 capabilities:
|
||||
*
|
||||
* 1. Calls the new /api/policy/decide endpoint (purpose-built for live
|
||||
* IPC with richer state including all zones, stack, and priority)
|
||||
* instead of the batch-oriented /api/ml/predict.
|
||||
*
|
||||
* 2. Collects (state, action, reward) tuples in real-time during live
|
||||
* Forge games and flushes them to the policy server for online PPO
|
||||
* updates via POST /api/policy/collect.
|
||||
*
|
||||
* Falls back to ForgeBuiltinPolicy heuristics if the policy server
|
||||
* is unreachable or returns an error.
|
||||
*
|
||||
* Usage:
|
||||
* PolicyClient client = new PolicyClient("http://localhost:8080");
|
||||
* if (client.connect()) {
|
||||
* // At each Forge decision point:
|
||||
* String action = client.decide(fullGameState);
|
||||
* // After game ends:
|
||||
* client.submitReward(gameId, reward);
|
||||
* client.flushCollectedTuples();
|
||||
* }
|
||||
*
|
||||
* Refs: Issue #83 Phase 2.1
|
||||
*/
|
||||
public class PolicyClient {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().create();
|
||||
private static final int CONNECT_TIMEOUT_MS = 2000;
|
||||
private static final int READ_TIMEOUT_MS = 30_000;
|
||||
|
||||
private final String baseUrl;
|
||||
private final LearnedPolicy learnedPolicy;
|
||||
private volatile boolean serverAvailable = false;
|
||||
private volatile String lastError = "";
|
||||
|
||||
// Online learning tuple buffer
|
||||
private final ConcurrentLinkedQueue<JsonObject> tupleBuffer = new ConcurrentLinkedQueue<>();
|
||||
private static final int FLUSH_THRESHOLD = 64;
|
||||
|
||||
// Stats
|
||||
private int totalDecisions = 0;
|
||||
private int policyDecisions = 0;
|
||||
private int fallbackDecisions = 0;
|
||||
private long totalLatencyMs = 0;
|
||||
|
||||
public PolicyClient(String serverUrl) {
|
||||
this.baseUrl = serverUrl.endsWith("/")
|
||||
? serverUrl.substring(0, serverUrl.length() - 1)
|
||||
: serverUrl;
|
||||
this.learnedPolicy = new LearnedPolicy(serverUrl);
|
||||
}
|
||||
|
||||
public PolicyClient(String serverUrl, String playstyle, boolean greedy) {
|
||||
this(serverUrl);
|
||||
this.learnedPolicy.setPlaystyle(playstyle);
|
||||
this.learnedPolicy.setGreedy(greedy);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Connection
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Check that the policy server's /api/policy/decide endpoint is live
|
||||
* and a model is loaded. Falls back to checking /api/ml/model if the
|
||||
* new endpoint isn't available yet.
|
||||
*/
|
||||
public boolean connect() {
|
||||
try {
|
||||
String json = httpGet(baseUrl + "/api/policy/health");
|
||||
serverAvailable = json.contains("\"ready\":true")
|
||||
|| json.contains("\"status\":\"ok\"");
|
||||
} catch (Exception e) {
|
||||
// Fall back to legacy health check
|
||||
serverAvailable = learnedPolicy.connect();
|
||||
}
|
||||
if (serverAvailable) {
|
||||
System.out.println("[PolicyClient] Connected to policy server");
|
||||
} else {
|
||||
System.out.println("[PolicyClient] Server unavailable, using Forge built-in AI");
|
||||
}
|
||||
return serverAvailable;
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Phase 2.1 — Live decision endpoint
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Request a macro-action decision from the policy server using the
|
||||
* full Forge game state (all zones, stack, priority).
|
||||
*
|
||||
* This calls POST /api/policy/decide which is purpose-built for the
|
||||
* live IPC loop, as opposed to /api/ml/predict which was designed
|
||||
* for batch inference.
|
||||
*
|
||||
* @param gameState Full game state JSON with all zones and priority info
|
||||
* @return macro-action name (e.g., "cast_creature"), or "pass" on failure
|
||||
*/
|
||||
public String decide(JsonObject gameState) {
|
||||
totalDecisions++;
|
||||
long t0 = System.currentTimeMillis();
|
||||
|
||||
if (!serverAvailable) {
|
||||
fallbackDecisions++;
|
||||
return MacroActionExecutor.PASS;
|
||||
}
|
||||
|
||||
try {
|
||||
// Add inference params
|
||||
gameState.addProperty("playstyle", learnedPolicy.getPlaystyle());
|
||||
gameState.addProperty("greedy", learnedPolicy.isGreedy());
|
||||
|
||||
String responseJson = httpPost(
|
||||
baseUrl + "/api/policy/decide",
|
||||
gameState.toString()
|
||||
);
|
||||
|
||||
JsonObject resp = GSON.fromJson(responseJson, JsonObject.class);
|
||||
long elapsed = System.currentTimeMillis() - t0;
|
||||
totalLatencyMs += elapsed;
|
||||
|
||||
if (resp.has("action") && !resp.has("error")) {
|
||||
policyDecisions++;
|
||||
String action = resp.get("action").getAsString();
|
||||
|
||||
// Store tuple for online learning
|
||||
collectTuple(gameState, action, resp);
|
||||
|
||||
return action;
|
||||
} else {
|
||||
fallbackDecisions++;
|
||||
lastError = resp.has("error")
|
||||
? resp.get("error").getAsString()
|
||||
: "Unknown error";
|
||||
return MacroActionExecutor.PASS;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
fallbackDecisions++;
|
||||
lastError = e.getMessage();
|
||||
return MacroActionExecutor.PASS;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: decide from a DecisionSnapshot (delegates to the
|
||||
* existing LearnedPolicy path if /api/policy/decide is unavailable).
|
||||
*/
|
||||
public String decide(DecisionSnapshot snapshot) {
|
||||
return learnedPolicy.decideAction(snapshot);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a decision to a concrete Forge action.
|
||||
*/
|
||||
public MacroActionExecutor.ActionResolution resolveAction(
|
||||
JsonObject gameState,
|
||||
List<String> hand,
|
||||
List<String> battlefield,
|
||||
List<String> commandZone,
|
||||
int manaAvailable,
|
||||
String commanderName,
|
||||
int commanderTax
|
||||
) {
|
||||
String macroAction = decide(gameState);
|
||||
return MacroActionExecutor.resolve(
|
||||
macroAction, hand, battlefield, commandZone,
|
||||
manaAvailable, commanderName, commanderTax
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Phase 2.1 — Online learning tuple collection
|
||||
// ================================================================
|
||||
|
||||
/**
|
||||
* Buffer a (state, action, prediction_metadata) tuple for later
|
||||
* submission to the policy server for online PPO updates.
|
||||
*/
|
||||
private void collectTuple(JsonObject state, String action, JsonObject prediction) {
|
||||
JsonObject tuple = new JsonObject();
|
||||
tuple.add("state", state);
|
||||
tuple.addProperty("action", action);
|
||||
if (prediction.has("action_index")) {
|
||||
tuple.addProperty("action_index", prediction.get("action_index").getAsInt());
|
||||
}
|
||||
if (prediction.has("log_prob")) {
|
||||
tuple.addProperty("log_prob", prediction.get("log_prob").getAsDouble());
|
||||
}
|
||||
if (prediction.has("value")) {
|
||||
tuple.addProperty("value", prediction.get("value").getAsDouble());
|
||||
}
|
||||
tuple.addProperty("timestamp", System.currentTimeMillis());
|
||||
|
||||
tupleBuffer.add(tuple);
|
||||
|
||||
// Auto-flush when buffer is full
|
||||
if (tupleBuffer.size() >= FLUSH_THRESHOLD) {
|
||||
flushCollectedTuples();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit end-of-game reward for all tuples in the current game.
|
||||
* Call this after a Forge game completes.
|
||||
*/
|
||||
public void submitReward(String gameId, double reward) {
|
||||
try {
|
||||
JsonObject body = new JsonObject();
|
||||
body.addProperty("game_id", gameId);
|
||||
body.addProperty("reward", reward);
|
||||
httpPost(baseUrl + "/api/policy/reward", body.toString());
|
||||
} catch (Exception e) {
|
||||
System.err.println("[PolicyClient] Failed to submit reward: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush buffered (state, action, reward) tuples to the policy server
|
||||
* for online PPO updates via POST /api/policy/collect.
|
||||
*/
|
||||
public void flushCollectedTuples() {
|
||||
if (tupleBuffer.isEmpty()) return;
|
||||
|
||||
JsonArray batch = new JsonArray();
|
||||
JsonObject tuple;
|
||||
while ((tuple = tupleBuffer.poll()) != null) {
|
||||
batch.add(tuple);
|
||||
}
|
||||
|
||||
try {
|
||||
JsonObject body = new JsonObject();
|
||||
body.add("tuples", batch);
|
||||
body.addProperty("count", batch.size());
|
||||
httpPost(baseUrl + "/api/policy/collect", body.toString());
|
||||
System.out.println("[PolicyClient] Flushed " + batch.size() + " tuples");
|
||||
} catch (Exception e) {
|
||||
System.err.println("[PolicyClient] Failed to flush tuples: " + e.getMessage());
|
||||
// Re-queue on failure
|
||||
for (int i = 0; i < batch.size(); i++) {
|
||||
tupleBuffer.add(batch.get(i).getAsJsonObject());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Accessors
|
||||
// ================================================================
|
||||
|
||||
public boolean isServerAvailable() { return serverAvailable; }
|
||||
public String getLastError() { return lastError; }
|
||||
public int getTotalDecisions() { return totalDecisions; }
|
||||
public int getPolicyDecisions() { return policyDecisions; }
|
||||
public String getBaseUrl() { return baseUrl; }
|
||||
|
||||
/** Decision result record for GameSession integration. */
|
||||
public record PolicyDecision(String action, double confidence) {}
|
||||
|
||||
/**
|
||||
* Decide from a JSON string + seat index. Used by GameSession.
|
||||
* Converts to JsonObject and delegates to decide(JsonObject).
|
||||
*/
|
||||
public PolicyDecision decide(String stateJson, int seat) {
|
||||
JsonObject state = GSON.fromJson(stateJson, JsonObject.class);
|
||||
state.addProperty("seat", seat);
|
||||
String action = decide(state);
|
||||
return new PolicyDecision(action, 1.0);
|
||||
}
|
||||
|
||||
public int getFallbackDecisions() { return fallbackDecisions; }
|
||||
public int getPendingTuples() { return tupleBuffer.size(); }
|
||||
|
||||
public double getAverageLatencyMs() {
|
||||
return policyDecisions > 0
|
||||
? (double) totalLatencyMs / policyDecisions
|
||||
: 0;
|
||||
}
|
||||
|
||||
public String getStatsSummary() {
|
||||
return String.format(
|
||||
"PolicyClient[decisions=%d, policy=%d, fallback=%d, avg=%.1fms, pending=%d]",
|
||||
totalDecisions, policyDecisions, fallbackDecisions,
|
||||
getAverageLatencyMs(), tupleBuffer.size()
|
||||
);
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// HTTP helpers
|
||||
// ================================================================
|
||||
|
||||
private String httpGet(String url) throws IOException {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) {
|
||||
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
||||
}
|
||||
return readStream(conn.getInputStream());
|
||||
}
|
||||
|
||||
private String httpPost(String url, String jsonBody) throws IOException {
|
||||
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
|
||||
conn.setReadTimeout(READ_TIMEOUT_MS);
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setRequestProperty("Accept", "application/json");
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code != 200) {
|
||||
throw new IOException("HTTP " + code + ": " + readStream(conn.getErrorStream()));
|
||||
}
|
||||
return readStream(conn.getInputStream());
|
||||
}
|
||||
|
||||
private String readStream(InputStream stream) throws IOException {
|
||||
if (stream == null) return "";
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
sb.append(line);
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,6 @@ import java.util.concurrent.atomic.AtomicLong;
|
||||
* - Issue #5: Auto-detects optimal thread count, configurable cap,
|
||||
* real-time sims/sec reporting, graceful backpressure
|
||||
*
|
||||
* Phase 3 (Issue #83): Persistent JVM worker pool — eliminates per-game
|
||||
* JVM startup overhead by reusing GameRules and CardDb across games.
|
||||
* - Workers are pre-warmed at construction time
|
||||
* - Pool size matches numThreads (one warm worker per thread slot)
|
||||
* - Target throughput: 10-50 games/sec per thread
|
||||
*
|
||||
* Each thread gets its own BatchRunner instance and a per-thread RNG seed
|
||||
* derived from the master seed. Results are merged after all threads complete.
|
||||
*/
|
||||
@@ -49,17 +43,6 @@ public class MultiThreadBatchRunner {
|
||||
private boolean useSimplifiedAi = false;
|
||||
private int aiThinkTimeMs = -1;
|
||||
|
||||
// ── ML Decision Logging ──────────────────────────────────────
|
||||
private boolean mlLogEnabled = false;
|
||||
private String mlOutputDir = "results";
|
||||
private String mlBatchId;
|
||||
|
||||
// ── Phase 3: Persistent JVM worker pool ──────────────────────
|
||||
// Pre-warmed BatchRunner instances — one per thread slot.
|
||||
// Reusing them avoids per-game JVM startup cost (GameRules, CardDb init).
|
||||
private final List<BatchRunner> workerPool = new ArrayList<>();
|
||||
private boolean poolWarmed = false;
|
||||
|
||||
public MultiThreadBatchRunner(String forgeJarPath, String forgeWorkDir,
|
||||
List<DeckInfo> decks, AiPolicy policy, int numThreads) {
|
||||
this(forgeJarPath, forgeWorkDir, decks, policy, numThreads, true, 120, "java");
|
||||
@@ -84,104 +67,65 @@ public class MultiThreadBatchRunner {
|
||||
this.javaPath = (javaPath != null && !javaPath.isEmpty()) ? javaPath : "java";
|
||||
}
|
||||
|
||||
// ── Configuration setters ────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Set progress callback for real-time reporting (Issue #5).
|
||||
*/
|
||||
public void setProgressCallback(BatchRunner.ProgressCallback callback) {
|
||||
this.progressCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set max subprocess queue depth for backpressure (Issue #5).
|
||||
* If the number of running Forge subprocesses exceeds this limit,
|
||||
* new game spawns are throttled until slots free up.
|
||||
*
|
||||
* @param maxDepth Max concurrent subprocesses (-1 for unlimited)
|
||||
*/
|
||||
public void setMaxQueueDepth(int maxDepth) {
|
||||
this.maxQueueDepth = maxDepth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure AI optimization flags (Issue #4).
|
||||
*/
|
||||
public void setAiOptimization(boolean simplified, int thinkTimeMs) {
|
||||
this.useSimplifiedAi = simplified;
|
||||
this.aiThinkTimeMs = thinkTimeMs;
|
||||
}
|
||||
|
||||
public void enableMlLogging(String outputDir, String batchId) {
|
||||
this.mlLogEnabled = true;
|
||||
this.mlOutputDir = outputDir;
|
||||
this.mlBatchId = batchId;
|
||||
}
|
||||
|
||||
// ── Phase 3: Worker pool management ─────────────────────────
|
||||
|
||||
/**
|
||||
* Pre-warm the JVM worker pool. Creates one BatchRunner per thread slot
|
||||
* and runs a single no-op warm-up pass so that GameRules and CardDb are
|
||||
* loaded and cached before the first real game batch begins.
|
||||
*
|
||||
* Call this once after construction, before runBatch(). If not called
|
||||
* explicitly, runBatch() will warm the pool automatically on first use.
|
||||
*
|
||||
* Target: eliminates ~2-5s per-game JVM startup overhead, enabling
|
||||
* 10-50 games/sec per thread (Phase 3 success criterion).
|
||||
*/
|
||||
public synchronized void warmPool() {
|
||||
if (poolWarmed) return;
|
||||
|
||||
int effective = numThreads <= 0 ? detectOptimalThreads() : numThreads;
|
||||
System.out.printf("[POOL] Pre-warming %d JVM worker(s)...%n", effective);
|
||||
long warmStart = System.currentTimeMillis();
|
||||
|
||||
workerPool.clear();
|
||||
for (int i = 0; i < effective; i++) {
|
||||
BatchRunner worker = createWorker(i);
|
||||
workerPool.add(worker);
|
||||
}
|
||||
|
||||
long warmMs = System.currentTimeMillis() - warmStart;
|
||||
System.out.printf("[POOL] Worker pool ready (%d workers) in %.1fs%n",
|
||||
effective, warmMs / 1000.0);
|
||||
poolWarmed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the worker pool and release resources.
|
||||
* Call after all runBatch() calls are complete.
|
||||
*/
|
||||
public synchronized void shutdownPool() {
|
||||
for (BatchRunner worker : workerPool) {
|
||||
try {
|
||||
worker.closeMlLogger();
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
workerPool.clear();
|
||||
poolWarmed = false;
|
||||
System.out.println("[POOL] Worker pool shut down.");
|
||||
}
|
||||
|
||||
private BatchRunner createWorker(int threadId) {
|
||||
BatchRunner worker = new BatchRunner(
|
||||
forgeJarPath, forgeWorkDir, decks, policy, mlLogEnabled ? false : quiet, clockSeconds, javaPath);
|
||||
worker.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||
if (mlLogEnabled) {
|
||||
String threadBatchId = mlBatchId + "-t" + threadId;
|
||||
worker.enableMlLogging(mlOutputDir, threadBatchId);
|
||||
}
|
||||
return worker;
|
||||
}
|
||||
|
||||
// ── Phase 3: Thread count auto-detection ─────────────────────
|
||||
|
||||
/**
|
||||
* Auto-detect optimal thread count based on available CPU cores (Issue #5).
|
||||
*
|
||||
* Uses physical core count (not logical/hyperthreaded) when possible.
|
||||
* Each Forge subprocess is CPU-intensive with little I/O wait, so
|
||||
* hyperthreading provides minimal benefit and may increase contention.
|
||||
*
|
||||
* @return Recommended thread count for this machine
|
||||
*/
|
||||
public static int detectOptimalThreads() {
|
||||
int availableProcessors = Runtime.getRuntime().availableProcessors();
|
||||
|
||||
// On most systems, availableProcessors includes hyperthreads.
|
||||
// For CPU-bound Forge sims, physical cores are what matter.
|
||||
// Heuristic: assume HT gives 2x logical cores on Intel/AMD
|
||||
int estimatedPhysical = availableProcessors;
|
||||
|
||||
// Try to detect if hyperthreading is active
|
||||
// On Windows, we can check if logical > physical
|
||||
try {
|
||||
String os = System.getProperty("os.name", "").toLowerCase();
|
||||
if (os.contains("win")) {
|
||||
// Windows: NUMBER_OF_PROCESSORS env var = logical processors
|
||||
// For a more accurate count, we'd need WMI or /proc — but the
|
||||
// simple heuristic of logical/2 works for most desktop systems
|
||||
// Only apply if > 4 logical cores (small systems likely don't have HT)
|
||||
if (availableProcessors > 4) {
|
||||
estimatedPhysical = availableProcessors / 2;
|
||||
}
|
||||
} else if (os.contains("linux")) {
|
||||
java.io.File topology = new java.io.File(
|
||||
"/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||
// Linux: try reading /sys/devices/system/cpu/cpu0/topology/thread_siblings_list
|
||||
// If the file exists and shows 2 threads per core, halve the count
|
||||
java.io.File topology = new java.io.File("/sys/devices/system/cpu/cpu0/topology/thread_siblings_list");
|
||||
if (topology.exists()) {
|
||||
String content = java.nio.file.Files.readString(topology.toPath()).trim();
|
||||
if (content.contains(",") || content.contains("-")) {
|
||||
@@ -193,54 +137,47 @@ public class MultiThreadBatchRunner {
|
||||
// Ignore — use availableProcessors as-is
|
||||
}
|
||||
|
||||
// Reserve 1 core for the OS/JVM overhead, minimum 1 worker
|
||||
int optimal = Math.max(1, estimatedPhysical - 1);
|
||||
|
||||
System.out.printf("[THREADS] Detected: %d logical processors, ~%d physical cores → using %d threads%n",
|
||||
availableProcessors, estimatedPhysical, optimal);
|
||||
|
||||
return optimal;
|
||||
}
|
||||
|
||||
// ── Main batch runner ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Run totalGames across numThreads workers.
|
||||
*
|
||||
* Phase 3: Uses the persistent worker pool if available (warmPool() called
|
||||
* beforehand), otherwise falls back to creating per-run workers. Pool reuse
|
||||
* eliminates per-game JVM startup cost for GameRules and CardDb.
|
||||
* v24: Real-time progress reporting, backpressure, per-game sims/sec (Issue #5).
|
||||
*/
|
||||
public List<GameResult> runBatch(int totalGames, Long masterSeed)
|
||||
throws InterruptedException, ExecutionException {
|
||||
|
||||
// Auto-detect if threads=0 or threads=-1
|
||||
public List<GameResult> runBatch(int totalGames, Long masterSeed) throws InterruptedException, ExecutionException {
|
||||
// Issue #5: Auto-detect if threads=0 or threads=-1 (auto mode)
|
||||
if (numThreads <= 0) {
|
||||
numThreads = detectOptimalThreads();
|
||||
}
|
||||
|
||||
// Phase 3: auto-warm pool on first runBatch() if not already warmed
|
||||
if (!poolWarmed) {
|
||||
warmPool();
|
||||
}
|
||||
|
||||
int gamesPerThread = totalGames / numThreads;
|
||||
int remainder = totalGames % numThreads;
|
||||
|
||||
// Issue #5: Backpressure — limit concurrent subprocesses if configured
|
||||
ExecutorService executor;
|
||||
if (maxQueueDepth > 0) {
|
||||
// Use a bounded thread pool to prevent RAM exhaustion from too many Forge JVMs
|
||||
int effectiveThreads = Math.min(numThreads, maxQueueDepth);
|
||||
executor = Executors.newFixedThreadPool(effectiveThreads);
|
||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes " +
|
||||
"(requested %d threads, max queue %d)%n",
|
||||
System.out.printf("[BACKPRESSURE] Limiting to %d concurrent Forge processes (requested %d threads, max queue %d)%n",
|
||||
effectiveThreads, numThreads, maxQueueDepth);
|
||||
} else {
|
||||
executor = Executors.newFixedThreadPool(numThreads);
|
||||
}
|
||||
|
||||
List<Future<List<GameResult>>> futures = new ArrayList<>();
|
||||
|
||||
totalCompleted.set(0);
|
||||
batchStartMs.set(System.currentTimeMillis());
|
||||
|
||||
System.out.printf("[BATCH] Starting %d games across %d pooled workers " +
|
||||
"(%.1f games/worker avg)%n",
|
||||
System.out.printf("[BATCH] Starting %d games across %d threads (%.1f games/thread avg)%n",
|
||||
totalGames, numThreads, (double) totalGames / numThreads);
|
||||
|
||||
int gameOffset = 0;
|
||||
@@ -248,37 +185,36 @@ public class MultiThreadBatchRunner {
|
||||
int chunkSize = gamesPerThread + (t < remainder ? 1 : 0);
|
||||
if (chunkSize == 0) continue;
|
||||
|
||||
Long threadSeed = (masterSeed != null)
|
||||
? masterSeed + (long) gameOffset * 1000L : null;
|
||||
Long threadSeed = (masterSeed != null) ? masterSeed + (long) gameOffset * 1000L : null;
|
||||
int startIndex = gameOffset;
|
||||
int threadId = t;
|
||||
|
||||
// Phase 3: retrieve pre-warmed worker from pool
|
||||
BatchRunner worker = (threadId < workerPool.size())
|
||||
? workerPool.get(threadId)
|
||||
: createWorker(threadId);
|
||||
BatchRunner runner = new BatchRunner(forgeJarPath, forgeWorkDir, decks, policy, quiet, clockSeconds, javaPath);
|
||||
|
||||
// Per-thread progress callback that aggregates across all threads
|
||||
worker.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
// Issue #4: Forward AI optimization settings
|
||||
runner.setAiOptimization(useSimplifiedAi, aiThinkTimeMs);
|
||||
|
||||
// Issue #5: Per-thread progress callback that aggregates across all threads
|
||||
runner.setProgressCallback((completed, total, pct, simsPerSec, lastResult) -> {
|
||||
int globalCompleted = totalCompleted.incrementAndGet();
|
||||
long elapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double globalSimsPerSec = elapsed > 0
|
||||
? (double) globalCompleted / (elapsed / 1000.0) : 0.0;
|
||||
? (double) globalCompleted / (elapsed / 1000.0)
|
||||
: 0.0;
|
||||
int globalPct = (int) ((double) globalCompleted * 100.0 / totalGames);
|
||||
|
||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec " +
|
||||
"(worker %d: %d/%d)%n",
|
||||
System.out.printf("[PROGRESS] %d/%d games (%.1f%%) — %.3f sims/sec (thread %d: %d/%d)%n",
|
||||
globalCompleted, totalGames, (double) globalPct,
|
||||
globalSimsPerSec, threadId, completed, total);
|
||||
|
||||
// Forward to external callback if set
|
||||
if (progressCallback != null) {
|
||||
progressCallback.onProgress(globalCompleted, totalGames,
|
||||
globalPct, globalSimsPerSec, lastResult);
|
||||
progressCallback.onProgress(globalCompleted, totalGames, globalPct, globalSimsPerSec, lastResult);
|
||||
}
|
||||
});
|
||||
|
||||
futures.add(executor.submit(() -> {
|
||||
List<GameResult> results = worker.runBatchSingleThread(chunkSize, threadSeed);
|
||||
List<GameResult> results = runner.runBatchSingleThread(chunkSize, threadSeed);
|
||||
for (int i = 0; i < results.size(); i++) {
|
||||
results.get(i).gameIndex = startIndex + i;
|
||||
}
|
||||
@@ -304,10 +240,8 @@ public class MultiThreadBatchRunner {
|
||||
|
||||
// Final throughput report
|
||||
long totalElapsed = System.currentTimeMillis() - batchStartMs.get();
|
||||
double finalSimsPerSec = totalElapsed > 0
|
||||
? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec " +
|
||||
"(across %d pooled workers)%n",
|
||||
double finalSimsPerSec = totalElapsed > 0 ? (double) merged.size() / (totalElapsed / 1000.0) : 0.0;
|
||||
System.out.printf("[BATCH] Completed %d games in %.1fs — %.3f sims/sec (across %d threads)%n",
|
||||
merged.size(), totalElapsed / 1000.0, finalSimsPerSec, numThreads);
|
||||
|
||||
return merged;
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import commanderailab.ai.PolicyClient;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.BlockingQueue;
|
||||
@@ -14,11 +13,10 @@ import java.util.function.Consumer;
|
||||
/**
|
||||
* GameSession — holds state for one live interactive Commander game.
|
||||
*
|
||||
* PHASE 2: integrates PolicyClient for bidirectional sync between
|
||||
* the Java game loop and the Python policy server.
|
||||
* PHASE 1 STUB: generates synthetic game state so you can verify the full
|
||||
* WebSocket contract round-trips correctly before wiring in real Forge IPC.
|
||||
*
|
||||
* AI turns now consult the policy server for macro-action decisions
|
||||
* instead of using random stub logic.
|
||||
* Replace run() in Phase 2 once the contract is verified end-to-end.
|
||||
*/
|
||||
public class GameSession {
|
||||
|
||||
@@ -29,7 +27,6 @@ public class GameSession {
|
||||
private final String forgeJarPath;
|
||||
private final String forgeWorkDir;
|
||||
private final BlockingQueue<String> humanActionQueue;
|
||||
private final PolicyClient policyClient;
|
||||
|
||||
private volatile boolean running = false;
|
||||
private volatile int turnNumber = 1;
|
||||
@@ -59,21 +56,11 @@ public class GameSession {
|
||||
public GameSession(List<String> deckNames, Long seed,
|
||||
String forgeJarPath, String forgeWorkDir,
|
||||
BlockingQueue<String> humanActionQueue) {
|
||||
this(deckNames, seed, forgeJarPath, forgeWorkDir, humanActionQueue,
|
||||
new PolicyClient("http://localhost:8080"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public GameSession(List<String> deckNames, Long seed,
|
||||
String forgeJarPath, String forgeWorkDir,
|
||||
BlockingQueue<String> humanActionQueue,
|
||||
PolicyClient policyClient) {
|
||||
this.deckNames = deckNames;
|
||||
this.seed = seed;
|
||||
this.forgeJarPath = forgeJarPath;
|
||||
this.forgeWorkDir = forgeWorkDir;
|
||||
this.humanActionQueue = humanActionQueue;
|
||||
this.policyClient = policyClient;
|
||||
|
||||
int seats = Math.min(deckNames.size(), 4);
|
||||
life = new int[seats];
|
||||
@@ -87,8 +74,8 @@ public class GameSession {
|
||||
for (int i = 0; i < seats; i++) {
|
||||
life[i] = 40;
|
||||
hands[i] = new ArrayList<>(List.of(
|
||||
"card_stub_1", "card_stub_2", "card_stub_3",
|
||||
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
|
||||
"card_stub_1", "card_stub_2", "card_stub_3",
|
||||
"card_stub_4", "card_stub_5", "card_stub_6", "card_stub_7"
|
||||
));
|
||||
battlefields[i] = new ArrayList<>();
|
||||
graveyards[i] = new ArrayList<>();
|
||||
@@ -98,27 +85,28 @@ public class GameSession {
|
||||
|
||||
public boolean isRunning() { return running; }
|
||||
public int getTurnNumber() { return turnNumber; }
|
||||
public void stop() { running = false; }
|
||||
|
||||
public void stop() {
|
||||
running = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the game loop. Calls onStateChange after every action resolves.
|
||||
* Blocks until the game ends or stop() is called.
|
||||
*
|
||||
* PHASE 2: AI turns now consult the PolicyClient for decisions.
|
||||
* The policy server returns a macro-action which is applied to game state.
|
||||
* Falls back to PASS_PRIORITY if the policy server is unreachable.
|
||||
* PHASE 1 STUB: advances through phases automatically; on MAIN1 of seat 0's
|
||||
* turn, pauses and waits for the human client to send an action.
|
||||
*/
|
||||
public void run(Consumer<Map<String, Object>> onStateChange) throws InterruptedException {
|
||||
public void run(Consumer<Object> onStateChange) throws InterruptedException {
|
||||
running = true;
|
||||
Random rng = seed != null ? new Random(seed) : new Random();
|
||||
|
||||
System.out.println("[Session] Game started. Decks: " + deckNames);
|
||||
System.out.println("[Session] PolicyClient endpoint: " + policyClient.getBaseUrl());
|
||||
|
||||
while (running && !isGameOver()) {
|
||||
String currentPhase = PHASES[phaseIndex];
|
||||
phase = currentPhase;
|
||||
activePlayer = (turnNumber - 1) % deckNames.size();
|
||||
|
||||
boolean isHumanTurn = (activePlayer == 0);
|
||||
boolean isInteractivePhase = currentPhase.equals("MAIN1") || currentPhase.equals("MAIN2");
|
||||
|
||||
@@ -129,6 +117,7 @@ public class GameSession {
|
||||
|
||||
System.out.println("[Session] Awaiting human action for " + currentPhase + "...");
|
||||
String actionJson = humanActionQueue.poll(120, TimeUnit.SECONDS);
|
||||
|
||||
if (actionJson == null) {
|
||||
System.out.println("[Session] Human timed out — auto passing.");
|
||||
} else {
|
||||
@@ -136,18 +125,13 @@ public class GameSession {
|
||||
System.out.println("[Session] Human action applied: " + actionJson);
|
||||
}
|
||||
awaitingHumanInput = false;
|
||||
|
||||
} else if (!isHumanTurn && isInteractivePhase) {
|
||||
// Phase 2: consult PolicyClient for AI decision
|
||||
priorityPlayer = activePlayer;
|
||||
Map<String, Object> snapshot = buildStateSnapshot();
|
||||
String policyAction = consultPolicy(activePlayer, snapshot);
|
||||
applyPolicyAction(activePlayer, policyAction, rng);
|
||||
System.out.println("[Session] AI-" + activePlayer + " policy action: " + policyAction);
|
||||
|
||||
} else {
|
||||
// Non-interactive phase — small delay for readability
|
||||
// AI turn — small delay for readability
|
||||
Thread.sleep(300);
|
||||
// Simulate AI occasionally dealing damage to human
|
||||
if (!isHumanTurn && currentPhase.equals("DAMAGE") && life[0] > 5) {
|
||||
life[0] -= rng.nextInt(4);
|
||||
}
|
||||
}
|
||||
|
||||
// Advance phase
|
||||
@@ -156,6 +140,7 @@ public class GameSession {
|
||||
phaseIndex = 0;
|
||||
turnNumber++;
|
||||
}
|
||||
|
||||
onStateChange.accept(buildStateSnapshot());
|
||||
}
|
||||
|
||||
@@ -163,70 +148,11 @@ public class GameSession {
|
||||
System.out.println("[Session] Game ended after " + turnNumber + " turns.");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Policy integration (Phase 2)
|
||||
// ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Consult the Python policy server for an AI decision.
|
||||
* Sends the current game snapshot and returns the recommended macro-action.
|
||||
* Falls back to PASS_PRIORITY on error.
|
||||
*/
|
||||
private String consultPolicy(int seat, Map<String, Object> snapshot) {
|
||||
try {
|
||||
String stateJson = GSON.toJson(snapshot);
|
||||
PolicyClient.PolicyDecision decision = policyClient.decide(stateJson, seat);
|
||||
if (decision != null && decision.action() != null) {
|
||||
System.out.println("[Session] Policy decision for seat " + seat
|
||||
+ ": " + decision.action()
|
||||
+ " (confidence=" + String.format("%.2f", decision.confidence()) + ")");
|
||||
return decision.action();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.err.println("[Session] Policy server error for seat " + seat + ": " + e.getMessage());
|
||||
}
|
||||
return "PASS_PRIORITY";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a policy-recommended macro-action for an AI player.
|
||||
*/
|
||||
private void applyPolicyAction(int seat, String action, Random rng) {
|
||||
switch (action) {
|
||||
case "CAST_SPELL" -> {
|
||||
if (!hands[seat].isEmpty()) {
|
||||
String card = hands[seat].remove(0);
|
||||
battlefields[seat].add(card);
|
||||
System.out.println("[Session] AI-" + seat + " cast: " + card);
|
||||
}
|
||||
}
|
||||
case "PLAY_LAND" -> {
|
||||
if (!hands[seat].isEmpty()) {
|
||||
String card = hands[seat].remove(hands[seat].size() - 1);
|
||||
battlefields[seat].add(card);
|
||||
}
|
||||
}
|
||||
case "ATTACK" -> {
|
||||
int target = (seat + 1) % life.length;
|
||||
if (life[target] > 0) {
|
||||
int dmg = rng.nextInt(3) + 1;
|
||||
life[target] -= dmg;
|
||||
System.out.println("[Session] AI-" + seat + " attacks player " + target + " for " + dmg);
|
||||
}
|
||||
}
|
||||
case "PASS_PRIORITY" -> { /* no-op */ }
|
||||
default -> System.out.println("[Session] Unknown policy action: " + action);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
// Human action handling
|
||||
// ---------------------------------------------------------
|
||||
|
||||
private void applyAction(String actionJson) {
|
||||
try {
|
||||
JsonObject action = JsonParser.parseString(actionJson).getAsJsonObject();
|
||||
String type = action.has("type") ? action.get("type").getAsString() : "PASS_PRIORITY";
|
||||
|
||||
switch (type) {
|
||||
case "PLAY_LAND", "CAST_SPELL" -> {
|
||||
String cardId = action.has("cardId") ? action.get("cardId").getAsString() : null;
|
||||
@@ -246,6 +172,7 @@ public class GameSession {
|
||||
private boolean isGameOver() {
|
||||
int alive = 0;
|
||||
for (int lp : life) if (lp > 0) alive++;
|
||||
// Phase 1 stub: end after 50 turns or only 1 player alive
|
||||
return alive <= 1 || turnNumber > 50;
|
||||
}
|
||||
|
||||
@@ -283,7 +210,7 @@ public class GameSession {
|
||||
p.put("poison", poison[i]);
|
||||
p.put("commanderTax", commanderTax[i]);
|
||||
p.put("handCount", hands[i].size());
|
||||
p.put("hand", i == 0 ? hands[i] : List.of());
|
||||
p.put("hand", i == 0 ? hands[i] : List.of()); // hide opponent hands
|
||||
p.put("battlefield", battlefields[i]);
|
||||
p.put("graveyard", graveyards[i]);
|
||||
p.put("commandZone", commandZones[i]);
|
||||
|
||||
@@ -90,7 +90,7 @@ public class LabCli implements Callable<Integer> {
|
||||
description = "Master RNG seed for reproducibility (blank for random)")
|
||||
private String seed;
|
||||
|
||||
@Option(names = {"--output", "-o"}, defaultValue = "results/ml-decision-latest.json",
|
||||
@Option(names = {"--output", "-o"}, defaultValue = "results/batch-latest.json",
|
||||
description = "Output JSON file path")
|
||||
private String outputPath;
|
||||
|
||||
@@ -344,12 +344,6 @@ public class LabCli implements Callable<Integer> {
|
||||
mtRunner.setAiOptimization(aiSimplified, aiThinkTimeMs);
|
||||
if (maxQueueDepth > 0) {
|
||||
mtRunner.setMaxQueueDepth(maxQueueDepth);
|
||||
}
|
||||
// v4: Enable ML decision logging if requested
|
||||
if (mlLog) {
|
||||
String batchId = UUID.randomUUID().toString().substring(0, 8);
|
||||
mtRunner.enableMlLogging("results", batchId);
|
||||
System.out.println("[ML] Decision logging enabled (multi-thread) — data will be saved to results/");
|
||||
}
|
||||
games = mtRunner.runBatch(numGames, masterSeed);
|
||||
} else {
|
||||
|
||||
@@ -33,7 +33,7 @@ public class PolicyServerClient {
|
||||
|
||||
private static final Gson GSON = new GsonBuilder().create();
|
||||
private static final int CONNECT_TIMEOUT_MS = 2000;
|
||||
private static final int READ_TIMEOUT_MS = 300_000;
|
||||
private static final int READ_TIMEOUT_MS = 5000;
|
||||
|
||||
private final String baseUrl;
|
||||
private volatile boolean available = false;
|
||||
|
||||
@@ -31,7 +31,7 @@ public class JsonExporter {
|
||||
* Write a BatchResult to a JSON file.
|
||||
*
|
||||
* @param result The batch result to export
|
||||
* @param filePath Output file path (e.g., "results/ml-decision-20260306-001.json")
|
||||
* @param filePath Output file path (e.g., "results/batch-20260306-001.json")
|
||||
* @throws IOException if file cannot be written
|
||||
*/
|
||||
public static void writeToFile(BatchResult result, String filePath) throws IOException {
|
||||
|
||||
+11
-111
@@ -26,56 +26,11 @@ os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||
class StubCard:
|
||||
name: str
|
||||
type: str = "creature"
|
||||
type_line: str = ""
|
||||
pt: Optional[str] = "2/2"
|
||||
cmc: int = 3
|
||||
tapped: bool = False
|
||||
is_commander: bool = False
|
||||
oracle: str = ""
|
||||
oracle_text: str = ""
|
||||
power: str = ""
|
||||
toughness: str = ""
|
||||
keywords: list = field(default_factory=list)
|
||||
|
||||
def get_power(self) -> int:
|
||||
if self.power:
|
||||
try:
|
||||
return int(self.power)
|
||||
except ValueError:
|
||||
return 0
|
||||
if not self.pt:
|
||||
return 0
|
||||
parts = self.pt.split("/")
|
||||
try:
|
||||
return int(parts[0])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
def get_toughness(self) -> int:
|
||||
if self.toughness:
|
||||
try:
|
||||
return int(self.toughness)
|
||||
except ValueError:
|
||||
return 0
|
||||
if not self.pt:
|
||||
return 0
|
||||
parts = self.pt.split("/")
|
||||
try:
|
||||
return int(parts[1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
def is_land(self) -> bool:
|
||||
return bool(self.type_line and "land" in self.type_line.lower())
|
||||
|
||||
def is_creature(self) -> bool:
|
||||
return bool(self.type_line and "creature" in self.type_line.lower())
|
||||
|
||||
def has_keyword(self, kw: str) -> bool:
|
||||
kw_lower = kw.lower()
|
||||
if self.keywords and any(k.lower() == kw_lower for k in self.keywords):
|
||||
return True
|
||||
return kw_lower in (self.oracle_text or "").lower()
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
@@ -92,7 +47,7 @@ class StubCard:
|
||||
@dataclass
|
||||
class StubPlayer:
|
||||
name: str
|
||||
seat: int = 0
|
||||
seat: int
|
||||
life: int = 40
|
||||
eliminated: bool = False
|
||||
hand: list = field(default_factory=list)
|
||||
@@ -120,28 +75,7 @@ class StubPlayer:
|
||||
}
|
||||
|
||||
|
||||
class _StubSimState:
|
||||
"""Minimal SimState-compatible stub — delegates to player battlefields."""
|
||||
|
||||
def __init__(self, players: list) -> None:
|
||||
self._players = players
|
||||
|
||||
def get_battlefield(self, seat: int) -> list:
|
||||
if 0 <= seat < len(self._players):
|
||||
return self._players[seat].battlefield
|
||||
return []
|
||||
|
||||
def add_to_battlefield(self, seat: int, card) -> None:
|
||||
if 0 <= seat < len(self._players):
|
||||
self._players[seat].battlefield.append(card)
|
||||
|
||||
|
||||
class StubGameState:
|
||||
"""
|
||||
Shared test stub that implements the CommanderGameState interface
|
||||
used by ThreatAssessor, TurnManager, and other subsystems.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
num_players: int = 4,
|
||||
@@ -152,27 +86,11 @@ class StubGameState:
|
||||
self.turn = 1
|
||||
self.current_phase = "main1"
|
||||
self.active_player_seat = 0
|
||||
self.priority_seat = 0
|
||||
self.stack: list = []
|
||||
self.stack = []
|
||||
self.game_over = False
|
||||
self.winner = None
|
||||
self.commander_damage: dict[tuple, int] = {}
|
||||
|
||||
# SimState-compatible sub-object for turn_manager._phase_untap() etc.
|
||||
self.sim_state = _StubSimState(self.players)
|
||||
|
||||
# -- CommanderGameState interface methods --
|
||||
|
||||
def battlefield(self, seat: int) -> list:
|
||||
"""Return the battlefield for *seat* (delegates to sim_state)."""
|
||||
return self.sim_state.get_battlefield(seat)
|
||||
|
||||
def stack_is_empty(self) -> bool:
|
||||
return len(self.stack) == 0
|
||||
|
||||
def living_players(self) -> list:
|
||||
return [p for p in self.players if not p.eliminated]
|
||||
|
||||
def get_legal_moves(self, seat: int) -> list[dict]:
|
||||
return [
|
||||
{"id": 1, "category": "pass_priority", "description": "Pass priority"},
|
||||
@@ -188,14 +106,6 @@ class StubGameState:
|
||||
if self.active_player_seat == 0:
|
||||
self.turn += 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"current_phase": self.current_phase,
|
||||
"active_player_seat": self.active_player_seat,
|
||||
"players": [p.to_dict() for p in self.players],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pytest fixtures
|
||||
@@ -211,20 +121,6 @@ def game_state_2p():
|
||||
return StubGameState(num_players=2, names=["Alice", "Bob"])
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_creature():
|
||||
"""A simple Card instance for clone and attribute tests."""
|
||||
from commander_ai_lab.sim.models import Card
|
||||
return Card(
|
||||
name="Grizzly Bears",
|
||||
type_line="Creature — Bear",
|
||||
cmc=2,
|
||||
pt="2/2",
|
||||
power="2",
|
||||
toughness="2",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def targeting_memory():
|
||||
from commander_ai_lab.sim.politics.memory import TargetingMemory
|
||||
@@ -261,12 +157,8 @@ def fresh_app():
|
||||
"""FastAPI test app with a clean session store."""
|
||||
from commander_ai_lab.web.session_store import SessionStore
|
||||
SessionStore._instance = None
|
||||
import commander_ai_lab.web.routers.api as api_mod
|
||||
api_mod._store = SessionStore()
|
||||
from commander_ai_lab.web.app import create_app
|
||||
app = create_app()
|
||||
yield app
|
||||
SessionStore._instance = None
|
||||
return create_app()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -275,6 +167,14 @@ def http_client(fresh_app):
|
||||
return TestClient(fresh_app)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_creature():
|
||||
"""A simple creature Card for model tests."""
|
||||
from commander_ai_lab.sim.models import Card
|
||||
return Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2", power="2", toughness="2")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def event_loop():
|
||||
"""Provide a fresh event loop for async tests."""
|
||||
|
||||
@@ -77,7 +77,7 @@ class TestDeckRatios:
|
||||
assert total == 99
|
||||
|
||||
def test_bad_total_raises(self):
|
||||
with pytest.raises(ValidationError, match="less_than_equal|must sum to 99"):
|
||||
with pytest.raises(ValidationError, match="must sum to 99"):
|
||||
DeckRatios(lands=50)
|
||||
|
||||
def test_out_of_range_raises(self):
|
||||
|
||||
@@ -1,539 +0,0 @@
|
||||
"""Regression tests for direct-damage spell routing (Issue #86).
|
||||
|
||||
Tests that burn spells and similar direct-damage effects are routed
|
||||
through the centralized deal_damage() path, correctly update
|
||||
life totals / stats, and integrate with commander damage tracking.
|
||||
|
||||
Adapted to work with the dual-dict commander damage model from PR #89:
|
||||
- commander_damage_received[seat] = per-seat aggregate
|
||||
- commander_damage_by_card[(seat, card_name)] = per-card breakdown
|
||||
"""
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _make_creature(name="Bear", pt="2/2", cmc=2, oracle_text="", **kwargs) -> Card:
|
||||
"""Build a creature Card with optional oracle text."""
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
cmc=cmc,
|
||||
pt=pt,
|
||||
power=pt.split("/")[0],
|
||||
toughness=pt.split("/")[1],
|
||||
oracle_text=oracle_text,
|
||||
**kwargs,
|
||||
)
|
||||
enrich_card(c)
|
||||
return c
|
||||
|
||||
|
||||
def _make_land(name="Forest") -> Card:
|
||||
c = Card(name=name)
|
||||
enrich_card(c)
|
||||
return c
|
||||
|
||||
|
||||
def _make_burn(name="Lightning Bolt", cmc=1, oracle_text="Lightning Bolt deals 3 damage to any target.") -> Card:
|
||||
c = Card(name=name, type_line="Instant", cmc=cmc, oracle_text=oracle_text)
|
||||
enrich_card(c)
|
||||
return c
|
||||
|
||||
|
||||
def _setup_two_player(
|
||||
p0_hand=None, p1_hand=None, p0_life=40, p1_life=40,
|
||||
p0_battlefield=None, p1_battlefield=None,
|
||||
) -> tuple[GameEngine, SimState]:
|
||||
"""Set up a 2-player SimState with controlled hands/battlefields."""
|
||||
engine = GameEngine(max_turns=5)
|
||||
sim = SimState(max_turns=5)
|
||||
for i, (hand, life, bf) in enumerate([
|
||||
(p0_hand or [], p0_life, p0_battlefield or []),
|
||||
(p1_hand or [], p1_life, p1_battlefield or []),
|
||||
]):
|
||||
p = Player(
|
||||
name=f"Player {i}",
|
||||
life=life,
|
||||
owner_id=i,
|
||||
hand=list(hand),
|
||||
library=[_make_land() for _ in range(30)],
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(2)
|
||||
for seat, bf_cards in enumerate([p0_battlefield or [], p1_battlefield or []]):
|
||||
for c in bf_cards:
|
||||
c.owner_id = seat
|
||||
c.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
c.turn_played = -1
|
||||
sim.add_to_battlefield(seat, c)
|
||||
return engine, sim
|
||||
|
||||
|
||||
def _setup_four_player(
|
||||
hands=None, lives=None, battlefields=None,
|
||||
) -> tuple[GameEngine, SimState]:
|
||||
"""Set up a 4-player SimState."""
|
||||
engine = GameEngine(max_turns=5)
|
||||
sim = SimState(max_turns=5)
|
||||
hands = hands or [[] for _ in range(4)]
|
||||
lives = lives or [40, 40, 40, 40]
|
||||
battlefields = battlefields or [[] for _ in range(4)]
|
||||
for i in range(4):
|
||||
p = Player(
|
||||
name=f"Player {i}",
|
||||
life=lives[i],
|
||||
owner_id=i,
|
||||
hand=list(hands[i]),
|
||||
library=[_make_land() for _ in range(30)],
|
||||
stats=PlayerStats(cards_drawn=7),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(4)
|
||||
for seat, bf_cards in enumerate(battlefields):
|
||||
for c in bf_cards:
|
||||
c.owner_id = seat
|
||||
c.id = sim.next_card_id
|
||||
sim.next_card_id += 1
|
||||
c.turn_played = -1
|
||||
sim.add_to_battlefield(seat, c)
|
||||
return engine, sim
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# deal_damage() centralized method tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDealDamage:
|
||||
"""Tests for GameEngine.deal_damage() static method."""
|
||||
|
||||
def test_basic_damage_reduces_life(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
dealt = engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert dealt == 5
|
||||
assert sim.players[1].life == 15
|
||||
|
||||
def test_zero_damage_is_noop(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
dealt = engine.deal_damage(sim, 0, target_seat=1)
|
||||
assert dealt == 0
|
||||
assert sim.players[1].life == 20
|
||||
|
||||
def test_negative_damage_is_noop(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
dealt = engine.deal_damage(sim, -3, target_seat=1)
|
||||
assert dealt == 0
|
||||
assert sim.players[1].life == 20
|
||||
|
||||
def test_damage_updates_stats(self):
|
||||
engine, sim = _setup_two_player()
|
||||
engine.deal_damage(sim, 7, target_seat=1, source_seat=0)
|
||||
assert sim.players[0].stats.damage_dealt == 7
|
||||
assert sim.players[1].stats.damage_received == 7
|
||||
|
||||
def test_damage_eliminates_at_zero(self):
|
||||
engine, sim = _setup_two_player(p1_life=3)
|
||||
engine.deal_damage(sim, 3, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == 0
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_damage_eliminates_below_zero(self):
|
||||
engine, sim = _setup_two_player(p1_life=2)
|
||||
engine.deal_damage(sim, 5, target_seat=1, source_seat=0)
|
||||
assert sim.players[1].life == -3
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_damage_to_eliminated_player_is_noop(self):
|
||||
engine, sim = _setup_two_player(p1_life=0)
|
||||
sim.players[1].eliminated = True
|
||||
dealt = engine.deal_damage(sim, 10, target_seat=1)
|
||||
assert dealt == 0
|
||||
assert sim.players[1].life == 0
|
||||
|
||||
def test_events_logged(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
events: list[str] = []
|
||||
engine.deal_damage(sim, 3, target_seat=1, source_seat=0,
|
||||
events=events, label="Bolt deals 3")
|
||||
assert len(events) == 1
|
||||
assert "Bolt deals 3" in events[0]
|
||||
assert "Player 1 now at 17 life" in events[0]
|
||||
|
||||
def test_no_label_means_no_event(self):
|
||||
engine, sim = _setup_two_player(p1_life=20)
|
||||
events: list[str] = []
|
||||
engine.deal_damage(sim, 3, target_seat=1, events=events)
|
||||
assert len(events) == 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Commander damage tracking through deal_damage()
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCommanderDamageTracking:
|
||||
"""Tests for commander damage tracked by deal_damage() using
|
||||
the dual-dict model: per-seat aggregate + per-card breakdown."""
|
||||
|
||||
def test_commander_damage_tracked_both_dicts(self):
|
||||
engine, sim = _setup_two_player()
|
||||
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
|
||||
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0, is_combat=True)
|
||||
# Per-seat aggregate
|
||||
assert sim.players[1].commander_damage_received[0] == 4
|
||||
# Per-card breakdown
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
|
||||
|
||||
def test_commander_damage_accumulates(self):
|
||||
engine, sim = _setup_two_player()
|
||||
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
|
||||
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
|
||||
engine.deal_damage(sim, 4, target_seat=1, source_card=cmd, source_seat=0)
|
||||
assert sim.players[1].commander_damage_received[0] == 8
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 8
|
||||
|
||||
def test_commander_21_damage_eliminates(self):
|
||||
engine, sim = _setup_two_player()
|
||||
cmd = _make_creature("Atraxa", pt="7/7", is_commander=True)
|
||||
for _ in range(3):
|
||||
engine.deal_damage(sim, 7, target_seat=1, source_card=cmd, source_seat=0)
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_partner_commanders_tracked_separately(self):
|
||||
engine, sim = _setup_two_player()
|
||||
cmd_a = _make_creature("Thrasios", pt="1/3", is_commander=True)
|
||||
cmd_b = _make_creature("Tymna", pt="2/2", is_commander=True)
|
||||
# 15 from Thrasios, 15 from Tymna — neither hits 21 alone
|
||||
for _ in range(15):
|
||||
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_a, source_seat=0)
|
||||
for _ in range(15):
|
||||
engine.deal_damage(sim, 1, target_seat=1, source_card=cmd_b, source_seat=0)
|
||||
# Per-card: each under 21
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Thrasios")] == 15
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Tymna")] == 15
|
||||
# Per-seat aggregate: 30 total
|
||||
assert sim.players[1].commander_damage_received[0] == 30
|
||||
# Life is 10, but not eliminated by commander damage (no single source >= 21)
|
||||
assert sim.players[1].life == 10
|
||||
assert sim.players[1].is_dead_to_commander_damage() is False
|
||||
|
||||
def test_non_commander_damage_not_tracked(self):
|
||||
engine, sim = _setup_two_player()
|
||||
regular = _make_creature("Bear", pt="2/2")
|
||||
engine.deal_damage(sim, 2, target_seat=1, source_card=regular, source_seat=0)
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
assert sim.players[1].commander_damage_by_card == {}
|
||||
|
||||
def test_is_dead_to_commander_damage(self):
|
||||
p = Player(name="Test", life=40)
|
||||
assert p.is_dead_to_commander_damage() is False
|
||||
p.commander_damage_by_card[(0, "Kozilek")] = 20
|
||||
p.commander_damage_received[0] = 20
|
||||
assert p.is_dead_to_commander_damage() is False
|
||||
p.commander_damage_by_card[(0, "Kozilek")] = 21
|
||||
p.commander_damage_received[0] = 21
|
||||
assert p.is_dead_to_commander_damage() is True
|
||||
|
||||
def test_commander_damage_eliminates_despite_high_life(self):
|
||||
"""A player at 100 life should still die to 21 commander damage."""
|
||||
engine, sim = _setup_two_player(p1_life=100)
|
||||
cmd = _make_creature("Voltron", pt="21/21", is_commander=True)
|
||||
engine.deal_damage(sim, 21, target_seat=1, source_card=cmd, source_seat=0)
|
||||
assert sim.players[1].life == 79
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Direct-damage spell detection (rules.py)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDirectDamageDetection:
|
||||
"""Tests for is_direct_damage detection in enrich_card/rules."""
|
||||
|
||||
def test_lightning_bolt_detected(self):
|
||||
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1,
|
||||
oracle_text="Lightning Bolt deals 3 damage to any target.")
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 3
|
||||
|
||||
def test_shock_detected(self):
|
||||
c = Card(name="Shock", type_line="Instant", cmc=1,
|
||||
oracle_text="Shock deals 2 damage to any target.")
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 2
|
||||
|
||||
def test_known_burn_by_name(self):
|
||||
"""Known burn spells should be detected even without oracle text."""
|
||||
c = Card(name="Lightning Bolt")
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 3
|
||||
|
||||
def test_chain_lightning_detected(self):
|
||||
c = Card(name="Chain Lightning")
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 3
|
||||
|
||||
def test_boros_charm_detected(self):
|
||||
c = Card(name="Boros Charm")
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 4
|
||||
|
||||
def test_oracle_text_detection(self):
|
||||
"""An unknown card with direct-damage oracle text should be detected."""
|
||||
c = Card(
|
||||
name="Custom Burn Spell",
|
||||
type_line="Instant",
|
||||
cmc=2,
|
||||
oracle_text="Custom Burn Spell deals 5 damage to target creature or player.",
|
||||
)
|
||||
enrich_card(c)
|
||||
assert c.is_direct_damage is True
|
||||
assert c.direct_damage_amount == 5
|
||||
|
||||
def test_board_wipe_not_flagged(self):
|
||||
"""Board wipes should not be flagged as direct damage."""
|
||||
c = Card(name="Blasphemous Act", type_line="Sorcery", cmc=9,
|
||||
oracle_text="Blasphemous Act deals 13 damage to each creature.")
|
||||
enrich_card(c)
|
||||
assert c.is_board_wipe is True
|
||||
assert c.is_direct_damage is False
|
||||
|
||||
def test_creature_not_flagged(self):
|
||||
"""A vanilla creature should not be flagged as direct damage."""
|
||||
c = _make_creature("Grizzly Bears", pt="2/2")
|
||||
assert c.is_direct_damage is False
|
||||
|
||||
def test_removal_spell_not_flagged_as_direct_damage(self):
|
||||
"""Pure removal (destroy target) should not become direct damage."""
|
||||
c = Card(name="Murder", type_line="Instant", cmc=3,
|
||||
oracle_text="Destroy target creature.")
|
||||
enrich_card(c)
|
||||
assert c.is_removal is True
|
||||
assert c.is_direct_damage is False
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Direct-damage routing through _play_spells()
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestDirectDamageSpellRouting:
|
||||
"""Tests that burn spells resolve through deal_damage() during gameplay."""
|
||||
|
||||
def test_burn_spell_reduces_opponent_life(self):
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[bolt],
|
||||
p0_battlefield=lands,
|
||||
p1_life=20,
|
||||
)
|
||||
events: list[str] = []
|
||||
engine._play_spells(sim, 0, available_mana=3, events=events)
|
||||
# Lightning Bolt: 3 damage
|
||||
assert sim.players[1].life == 17
|
||||
assert sim.players[0].stats.damage_dealt == 3
|
||||
assert sim.players[1].stats.damage_received == 3
|
||||
|
||||
def test_burn_spell_goes_to_graveyard(self):
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[bolt],
|
||||
p0_battlefield=lands,
|
||||
)
|
||||
engine._play_spells(sim, 0, available_mana=3)
|
||||
# Bolt should be in graveyard
|
||||
assert any(c.name == "Lightning Bolt" for c in sim.players[0].graveyard)
|
||||
|
||||
def test_burn_spell_eliminates_low_life_opponent(self):
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[bolt],
|
||||
p0_battlefield=lands,
|
||||
p1_life=2,
|
||||
)
|
||||
engine._play_spells(sim, 0, available_mana=3)
|
||||
assert sim.players[1].life == -1
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
def test_burn_targets_weakest_opponent(self):
|
||||
"""In multiplayer, burn should target the weakest opponent."""
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_four_player(
|
||||
hands=[[bolt], [], [], []],
|
||||
lives=[40, 30, 10, 20],
|
||||
battlefields=[lands, [], [], []],
|
||||
)
|
||||
engine._play_spells(sim, 0, available_mana=3)
|
||||
# Player 2 (life=10) is weakest, should be targeted
|
||||
assert sim.players[2].life == 7
|
||||
|
||||
def test_burn_events_logged(self):
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[bolt],
|
||||
p0_battlefield=lands,
|
||||
p1_life=20,
|
||||
)
|
||||
events: list[str] = []
|
||||
engine._play_spells(sim, 0, available_mana=3, events=events)
|
||||
burn_events = [e for e in events if "Lightning Bolt" in e and "damage" in e]
|
||||
assert len(burn_events) >= 1
|
||||
|
||||
def test_burn_spell_taps_lands(self):
|
||||
bolt = _make_burn("Lightning Bolt")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[bolt],
|
||||
p0_battlefield=lands,
|
||||
)
|
||||
engine._play_spells(sim, 0, available_mana=3)
|
||||
tapped_lands = [c for c in sim.get_battlefield(0) if c.is_land() and c.tapped]
|
||||
assert len(tapped_lands) == 1 # CMC 1
|
||||
|
||||
def test_shock_deals_2(self):
|
||||
shock = _make_burn("Shock", cmc=1, oracle_text="Shock deals 2 damage to any target.")
|
||||
lands = [_make_land() for _ in range(3)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_hand=[shock],
|
||||
p0_battlefield=lands,
|
||||
p1_life=20,
|
||||
)
|
||||
engine._play_spells(sim, 0, available_mana=3)
|
||||
assert sim.players[1].life == 18
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Combat damage routing through deal_damage()
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestCombatDamageRouting:
|
||||
"""Tests that combat damage routes through deal_damage()."""
|
||||
|
||||
def test_unblocked_creature_deals_damage(self):
|
||||
bear = _make_creature("Bear", pt="2/2")
|
||||
lands = [_make_land() for _ in range(5)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[bear] + lands,
|
||||
p1_life=20,
|
||||
)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].life == 18
|
||||
assert sim.players[0].stats.damage_dealt == 2
|
||||
assert sim.players[1].stats.damage_received == 2
|
||||
|
||||
def test_commander_combat_damage_tracked(self):
|
||||
cmd = _make_creature("Atraxa", pt="4/4", is_commander=True)
|
||||
lands = [_make_land() for _ in range(5)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[cmd] + lands,
|
||||
p1_life=40,
|
||||
)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].life == 36
|
||||
# Per-seat aggregate
|
||||
assert sim.players[1].commander_damage_received[0] == 4
|
||||
# Per-card breakdown
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Atraxa")] == 4
|
||||
|
||||
def test_non_commander_combat_damage_not_tracked_as_commander(self):
|
||||
bear = _make_creature("Bear", pt="3/3")
|
||||
lands = [_make_land() for _ in range(5)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[bear] + lands,
|
||||
p1_life=40,
|
||||
)
|
||||
engine._resolve_combat(sim, 0, turn=1)
|
||||
assert sim.players[1].life == 37
|
||||
assert sim.players[1].commander_damage_received == {}
|
||||
assert sim.players[1].commander_damage_by_card == {}
|
||||
|
||||
def test_commander_lethal_at_21(self):
|
||||
"""A commander dealing 21 damage should eliminate even at high life."""
|
||||
cmd = _make_creature("Voltron", pt="7/7", is_commander=True)
|
||||
lands = [_make_land() for _ in range(5)]
|
||||
engine, sim = _setup_two_player(
|
||||
p0_battlefield=[cmd] + lands,
|
||||
p1_life=100,
|
||||
)
|
||||
# Simulate 3 combat rounds
|
||||
for t in range(3):
|
||||
if sim.players[1].eliminated:
|
||||
break
|
||||
# Reset attacker for next combat
|
||||
cmd.tapped = False
|
||||
cmd.turn_played = -1
|
||||
engine._resolve_combat(sim, 0, turn=t + 1)
|
||||
assert sim.players[1].commander_damage_by_card[(0, "Voltron")] == 21
|
||||
assert sim.players[1].eliminated is True
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Full-game integration with burn spells
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestFullGameWithBurn:
|
||||
"""Integration tests running full games with burn spells in decks."""
|
||||
|
||||
def test_game_with_burn_completes(self):
|
||||
"""A game with burn spells in decks should complete without error."""
|
||||
deck_a = (
|
||||
[Card(name="Lightning Bolt") for _ in range(8)]
|
||||
+ [Card(name="Shock") for _ in range(8)]
|
||||
+ [Card(name="Forest") for _ in range(24)]
|
||||
+ [Card(name="Grizzly Bears") for _ in range(20)]
|
||||
)
|
||||
deck_b = (
|
||||
[Card(name="Forest") for _ in range(24)]
|
||||
+ [Card(name="Serra Angel") for _ in range(10)]
|
||||
+ [Card(name="Grizzly Bears") for _ in range(20)]
|
||||
+ [Card(name="Murder") for _ in range(6)]
|
||||
)
|
||||
for c in deck_a + deck_b:
|
||||
enrich_card(c)
|
||||
|
||||
engine = GameEngine(max_turns=25, record_log=True)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
name_a="Burn",
|
||||
name_b="Midrange",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_burn_damage_appears_in_stats(self):
|
||||
"""Burn spell damage should be reflected in player stats."""
|
||||
deck_a = (
|
||||
[Card(name="Lightning Bolt") for _ in range(15)]
|
||||
+ [Card(name="Mountain") for _ in range(45)]
|
||||
)
|
||||
deck_b = [Card(name="Forest") for _ in range(60)]
|
||||
for c in deck_a + deck_b:
|
||||
enrich_card(c)
|
||||
|
||||
engine = GameEngine(max_turns=25)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
name_a="Burn",
|
||||
name_b="Lands",
|
||||
)
|
||||
# Player A should have dealt some damage from burn spells
|
||||
pa_stats = result.player_a_stats
|
||||
assert pa_stats is not None
|
||||
assert pa_stats.damage_dealt > 0
|
||||
+16
-11
@@ -15,8 +15,9 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
||||
Card(name="Cultivate"),
|
||||
Card(name="Murder"),
|
||||
Card(name="Wrath of God"),
|
||||
Card(name="Grizzly Bears"),
|
||||
Card(name="Serra Angel"),
|
||||
Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2"),
|
||||
Card(name="Serra Angel", type_line="Creature — Angel", cmc=5, pt="4/4",
|
||||
keywords=["Flying", "Vigilance"]),
|
||||
]
|
||||
for c in card_pool:
|
||||
enrich_card(c)
|
||||
@@ -30,7 +31,8 @@ def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
||||
class TestGameEngineTwoPlayer:
|
||||
def test_game_completes(self):
|
||||
"""A 2-player game should finish without error."""
|
||||
engine = GameEngine()
|
||||
random.seed(42)
|
||||
engine = GameEngine(max_turns=25)
|
||||
deck_a = _make_deck(seed=1)
|
||||
deck_b = _make_deck(seed=2)
|
||||
result = engine.run(
|
||||
@@ -44,7 +46,8 @@ class TestGameEngineTwoPlayer:
|
||||
|
||||
def test_result_has_winner_or_draw(self):
|
||||
"""winner_seat must be 0, 1, or -1 (draw)."""
|
||||
engine = GameEngine()
|
||||
random.seed(99)
|
||||
engine = GameEngine(max_turns=25)
|
||||
deck = _make_deck(seed=5)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
@@ -55,11 +58,12 @@ class TestGameEngineTwoPlayer:
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_seeded_game_is_deterministic(self):
|
||||
"""Same engine config must produce identical results with same decks."""
|
||||
"""Same seed must produce identical results."""
|
||||
deck_a = _make_deck(seed=7)
|
||||
deck_b = _make_deck(seed=8)
|
||||
|
||||
engine1 = GameEngine()
|
||||
random.seed(42)
|
||||
engine1 = GameEngine(max_turns=25)
|
||||
result1 = engine1.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
@@ -67,7 +71,8 @@ class TestGameEngineTwoPlayer:
|
||||
name_b="Bob",
|
||||
)
|
||||
|
||||
engine2 = GameEngine()
|
||||
random.seed(42)
|
||||
engine2 = GameEngine(max_turns=25)
|
||||
result2 = engine2.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
@@ -75,14 +80,14 @@ class TestGameEngineTwoPlayer:
|
||||
name_b="Bob",
|
||||
)
|
||||
|
||||
# Both engines produce valid results (determinism may vary without seed)
|
||||
assert result1.winner_seat in (-1, 0, 1)
|
||||
assert result2.winner_seat in (-1, 0, 1)
|
||||
assert result1.winner_seat == result2.winner_seat
|
||||
assert result1.turns == result2.turns
|
||||
|
||||
def test_to_dict_serializable(self):
|
||||
"""Game result must serialize to a dict with expected keys."""
|
||||
import json
|
||||
engine = GameEngine()
|
||||
random.seed(77)
|
||||
engine = GameEngine(max_turns=25)
|
||||
deck = _make_deck(seed=3)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Tests for Forge card enrichment -- parser, enrich_card(), and score_card()."""
|
||||
from __future__ import annotations
|
||||
|
||||
import textwrap
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.rules import enrich_card, score_card
|
||||
|
||||
|
||||
FORGE_BASIC = textwrap.dedent("""\
|
||||
Name:Elvish Mystic
|
||||
ManaCost:G
|
||||
Types:Creature Elf Druid
|
||||
PT:1/1
|
||||
Oracle:{T}: Add {G}.
|
||||
""")
|
||||
|
||||
FORGE_ETB = textwrap.dedent("""\
|
||||
Name:Llanowar Elves
|
||||
ManaCost:G
|
||||
Types:Creature Elf Druid
|
||||
PT:1/1
|
||||
T:Mode$ChangesZone $ Origin$Graveyard $ Destination$Battlefield
|
||||
""")
|
||||
|
||||
FORGE_KEYWORDS = textwrap.dedent("""\
|
||||
Name:Serra Angel
|
||||
ManaCost:3 W W
|
||||
Types:Creature Angel
|
||||
PT:4/4
|
||||
K:Flying
|
||||
K:Vigilance
|
||||
""")
|
||||
|
||||
FORGE_STATIC = textwrap.dedent("""\
|
||||
Name:Glorious Anthem
|
||||
ManaCost:1 W W
|
||||
Types:Enchantment
|
||||
S:Mode$Continuous $ Affected$Creature.YouCtrl
|
||||
""")
|
||||
|
||||
FORGE_REPLACEMENT = textwrap.dedent("""\
|
||||
Name:Leyline of the Void
|
||||
ManaCost:2 B B
|
||||
Types:Enchantment
|
||||
R:Mode$ReplaceEvent $ Event$MovedToGraveyard
|
||||
""")
|
||||
|
||||
|
||||
# -- Parser unit tests --
|
||||
|
||||
class TestParseForgeFile:
|
||||
def _parse(self, txt: str, tmp_path: Path):
|
||||
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
|
||||
fpath = tmp_path / "card.txt"
|
||||
fpath.write_text(txt, encoding="utf-8")
|
||||
return _parse_forge_file(fpath)
|
||||
|
||||
def test_basic_fields(self, tmp_path):
|
||||
d = self._parse(FORGE_BASIC, tmp_path)
|
||||
assert d.name == "Elvish Mystic"
|
||||
assert d.cmc == 1
|
||||
assert d.pt == "1/1"
|
||||
assert "{T}: Add {G}" in d.oracle_text
|
||||
|
||||
def test_keywords_parsed(self, tmp_path):
|
||||
d = self._parse(FORGE_KEYWORDS, tmp_path)
|
||||
assert "Flying" in d.keywords and "Vigilance" in d.keywords
|
||||
|
||||
def test_cmc_generic_plus_colored(self, tmp_path):
|
||||
d = self._parse(FORGE_KEYWORDS, tmp_path)
|
||||
assert d.cmc == 5 # 3 + W + W
|
||||
|
||||
def test_etb_trigger_mode(self, tmp_path):
|
||||
d = self._parse(FORGE_ETB, tmp_path)
|
||||
assert "ChangesZone" in d.trigger_modes
|
||||
|
||||
def test_static_flag(self, tmp_path):
|
||||
d = self._parse(FORGE_STATIC, tmp_path)
|
||||
assert d.has_static_ability is True
|
||||
assert d.has_replacement_effect is False
|
||||
|
||||
def test_replacement_flag(self, tmp_path):
|
||||
d = self._parse(FORGE_REPLACEMENT, tmp_path)
|
||||
assert d.has_replacement_effect is True
|
||||
|
||||
def test_empty_file_returns_none(self, tmp_path):
|
||||
from commander_ai_lab.sim.forge_card_loader import _parse_forge_file
|
||||
fpath = tmp_path / "empty.txt"
|
||||
fpath.write_text("", encoding="utf-8")
|
||||
assert _parse_forge_file(fpath) is None
|
||||
|
||||
|
||||
# -- enrich_card() integration tests --
|
||||
|
||||
class TestEnrichCardForge:
|
||||
def _forge(self, **kwargs):
|
||||
from commander_ai_lab.sim.forge_card_loader import ForgeCardData
|
||||
base = dict(
|
||||
name="X", mana_cost="2 G", cmc=3, type_line="Creature Elf",
|
||||
oracle_text="", pt="2/2", keywords=["Flying"],
|
||||
trigger_modes=[], has_replacement_effect=False,
|
||||
has_static_ability=False,
|
||||
)
|
||||
base.update(kwargs)
|
||||
return ForgeCardData(**base)
|
||||
|
||||
def test_forge_applied_to_empty_card(self):
|
||||
card = Card(name="X")
|
||||
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=self._forge()):
|
||||
r = enrich_card(card)
|
||||
assert r.forge_enriched and r.cmc == 3 and r.power == "2"
|
||||
assert "Flying" in r.keywords
|
||||
|
||||
def test_skipped_when_has_real_data(self):
|
||||
card = Card(name="Sol Ring", cmc=1, type_line="Artifact")
|
||||
with patch("commander_ai_lab.sim.rules.lookup_forge_card") as m:
|
||||
enrich_card(card)
|
||||
m.assert_not_called()
|
||||
|
||||
def test_miss_falls_through_to_known_cards(self):
|
||||
card = Card(name="Sol Ring")
|
||||
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=None):
|
||||
r = enrich_card(card)
|
||||
assert r.type_line == "Artifact" and r.is_ramp is True
|
||||
|
||||
def test_oracle_flags_from_forge_text(self):
|
||||
card = Card(name="X")
|
||||
fd = self._forge(oracle_text="Destroy target creature.", keywords=[])
|
||||
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
|
||||
r = enrich_card(card)
|
||||
assert r.is_removal is True
|
||||
|
||||
def test_no_duplicate_keywords(self):
|
||||
card = Card(name="X", keywords=["Flying"])
|
||||
fd = self._forge(keywords=["Flying", "Haste"])
|
||||
with patch("commander_ai_lab.sim.rules.lookup_forge_card", return_value=fd):
|
||||
r = enrich_card(card)
|
||||
assert sum(1 for k in r.keywords if k.lower() == "flying") == 1
|
||||
assert any(k.lower() == "haste" for k in r.keywords)
|
||||
|
||||
|
||||
# -- score_card() trigger detection tests --
|
||||
|
||||
class TestScoreCardForge:
|
||||
def test_etb_via_forge_mode_equals_text_detection(self):
|
||||
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
|
||||
forge_trigger_modes=["ChangesZone"])
|
||||
text = Card(name="B", type_line="Creature", cmc=3, pt="3/3",
|
||||
oracle_text="When this enters the battlefield, gain 1 life.")
|
||||
none = Card(name="C", type_line="Creature", cmc=3, pt="3/3")
|
||||
sf, st, sn = score_card(forge), score_card(text), score_card(none)
|
||||
assert sf > sn and abs(sf - st) < 0.01
|
||||
|
||||
def test_attack_trig_via_forge_mode(self):
|
||||
forge = Card(name="A", type_line="Creature", cmc=3, pt="3/3",
|
||||
forge_trigger_modes=["Attacks"])
|
||||
none = Card(name="B", type_line="Creature", cmc=3, pt="3/3")
|
||||
assert score_card(forge) > score_card(none)
|
||||
@@ -1,315 +0,0 @@
|
||||
"""Tests for London mulligan and opening-hand validation (#86)."""
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.sim.models import Card, PlayerStats
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────
|
||||
|
||||
def _land(name: str = "Forest") -> Card:
|
||||
return enrich_card(Card(name=name))
|
||||
|
||||
|
||||
def _spell(name: str = "Grizzly Bears") -> Card:
|
||||
return enrich_card(Card(name=name))
|
||||
|
||||
|
||||
def _make_deck(lands: int = 24, spells: int = 36) -> list[Card]:
|
||||
"""Build a deck with an exact land/spell split."""
|
||||
deck: list[Card] = []
|
||||
for _ in range(lands):
|
||||
deck.append(_land())
|
||||
for _ in range(spells):
|
||||
deck.append(_spell())
|
||||
return deck
|
||||
|
||||
|
||||
def _all_lands_deck(size: int = 60) -> list[Card]:
|
||||
"""Deck of nothing but lands — forces 7-land opening hand."""
|
||||
return [_land() for _ in range(size)]
|
||||
|
||||
|
||||
def _no_lands_deck(size: int = 60) -> list[Card]:
|
||||
"""Deck with zero lands — forces 0-land opening hand."""
|
||||
return [_spell(f"Spell_{i}") for i in range(size)]
|
||||
|
||||
|
||||
# ── PlayerStats fields ────────────────────────────────────────
|
||||
|
||||
class TestPlayerStatsMultiganFields:
|
||||
def test_default_mulligans_zero(self):
|
||||
stats = PlayerStats()
|
||||
assert stats.mulligans == 0
|
||||
|
||||
def test_default_opening_hand_lands_zero(self):
|
||||
stats = PlayerStats()
|
||||
assert stats.opening_hand_lands == 0
|
||||
|
||||
def test_to_dict_includes_mulligan_keys(self):
|
||||
stats = PlayerStats(mulligans=2, opening_hand_lands=3)
|
||||
d = stats.to_dict()
|
||||
assert d["mulligans"] == 2
|
||||
assert d["openingHandLands"] == 3
|
||||
|
||||
|
||||
# ── _should_mulligan heuristic ────────────────────────────────
|
||||
|
||||
class TestShouldMulligan:
|
||||
def test_zero_lands_mulligans(self):
|
||||
hand = [_spell() for _ in range(7)]
|
||||
assert GameEngine._should_mulligan(hand, 0) is True
|
||||
|
||||
def test_one_land_mulligans(self):
|
||||
hand = [_land()] + [_spell() for _ in range(6)]
|
||||
assert GameEngine._should_mulligan(hand, 0) is True
|
||||
|
||||
def test_two_lands_keeps(self):
|
||||
hand = [_land(), _land()] + [_spell() for _ in range(5)]
|
||||
assert GameEngine._should_mulligan(hand, 0) is False
|
||||
|
||||
def test_five_lands_keeps(self):
|
||||
hand = [_land() for _ in range(5)] + [_spell(), _spell()]
|
||||
assert GameEngine._should_mulligan(hand, 0) is False
|
||||
|
||||
def test_six_lands_mulligans(self):
|
||||
hand = [_land() for _ in range(6)] + [_spell()]
|
||||
assert GameEngine._should_mulligan(hand, 0) is True
|
||||
|
||||
def test_seven_lands_mulligans(self):
|
||||
hand = [_land() for _ in range(7)]
|
||||
assert GameEngine._should_mulligan(hand, 0) is True
|
||||
|
||||
def test_desperate_keeps_one_land(self):
|
||||
"""After 2+ mulligans, accept a 1-land hand."""
|
||||
hand = [_land()] + [_spell() for _ in range(6)]
|
||||
assert GameEngine._should_mulligan(hand, 2) is False
|
||||
|
||||
|
||||
# ── _count_lands_in_hand ──────────────────────────────────────
|
||||
|
||||
class TestCountLandsInHand:
|
||||
def test_empty_hand(self):
|
||||
assert GameEngine._count_lands_in_hand([]) == 0
|
||||
|
||||
def test_mixed_hand(self):
|
||||
hand = [_land(), _spell(), _land(), _spell()]
|
||||
assert GameEngine._count_lands_in_hand(hand) == 2
|
||||
|
||||
def test_all_lands(self):
|
||||
hand = [_land() for _ in range(7)]
|
||||
assert GameEngine._count_lands_in_hand(hand) == 7
|
||||
|
||||
|
||||
# ── _pick_bottom_cards ────────────────────────────────────────
|
||||
|
||||
class TestPickBottomCards:
|
||||
def test_returns_correct_count(self):
|
||||
hand = [_land(), _land(), _spell(), _spell(), _spell()]
|
||||
bottom = GameEngine._pick_bottom_cards(hand, 2, {})
|
||||
assert len(bottom) == 2
|
||||
|
||||
def test_zero_count_returns_empty(self):
|
||||
hand = [_land(), _spell()]
|
||||
assert GameEngine._pick_bottom_cards(hand, 0, {}) == []
|
||||
|
||||
def test_count_gte_hand_returns_empty(self):
|
||||
hand = [_land()]
|
||||
assert GameEngine._pick_bottom_cards(hand, 1, {}) == []
|
||||
|
||||
def test_bottoms_lowest_scored_cards(self):
|
||||
"""The card with the worst AI score should be picked to bottom."""
|
||||
good = enrich_card(Card(name="Serra Angel", type_line="Creature", cmc=5, pt="4/4"))
|
||||
bad = enrich_card(Card(name="Forest"))
|
||||
hand = [good, bad, _spell()]
|
||||
bottom = GameEngine._pick_bottom_cards(hand, 1, {})
|
||||
# Lands score very low compared to creatures
|
||||
assert bottom[0].name == "Forest"
|
||||
|
||||
|
||||
# ── London mulligan integration ───────────────────────────────
|
||||
|
||||
class TestLondonMulliganIntegration:
|
||||
def test_good_hand_no_mulligan(self):
|
||||
"""A well-balanced deck should usually keep on the first try."""
|
||||
random.seed(42)
|
||||
deck = _make_deck(lands=24, spells=36)
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
# At least one player should have kept without mulligan
|
||||
any_kept = any(
|
||||
p.stats.mulligans == 0 for p in result.players
|
||||
)
|
||||
# With a 24-land deck, most seeds give keepable hands
|
||||
assert result.turns > 0 # game still works
|
||||
|
||||
def test_all_lands_deck_triggers_mulligan(self):
|
||||
"""A 60-land deck should always mulligan (7 lands in hand)."""
|
||||
random.seed(99)
|
||||
deck_lands = _all_lands_deck()
|
||||
deck_normal = _make_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_lands],
|
||||
deck_b=[c.clone() for c in deck_normal],
|
||||
)
|
||||
# Player A has an all-land deck; must have mulliganed at least once
|
||||
pa_stats = result.players[0].stats
|
||||
assert pa_stats.mulligans > 0
|
||||
|
||||
def test_no_lands_deck_triggers_mulligan(self):
|
||||
"""A 0-land deck should always mulligan (0 lands in hand)."""
|
||||
random.seed(77)
|
||||
deck_empty = _no_lands_deck()
|
||||
deck_normal = _make_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_empty],
|
||||
deck_b=[c.clone() for c in deck_normal],
|
||||
)
|
||||
pa_stats = result.players[0].stats
|
||||
assert pa_stats.mulligans > 0
|
||||
|
||||
def test_mulligan_caps_at_max(self):
|
||||
"""Even a terrible deck can mulligan at most MAX_MULLIGANS times."""
|
||||
random.seed(11)
|
||||
deck_empty = _no_lands_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_empty],
|
||||
deck_b=[c.clone() for c in deck_empty],
|
||||
)
|
||||
for p in result.players:
|
||||
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
|
||||
|
||||
def test_hand_size_after_mulligan(self):
|
||||
"""After N mulligans, opening hand should be 7 - N cards."""
|
||||
random.seed(55)
|
||||
deck_lands = _all_lands_deck()
|
||||
deck_normal = _make_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
# We need to test the internal state, so use _create_state directly
|
||||
sim = engine._create_state(
|
||||
decks=[[c.clone() for c in deck_lands], [c.clone() for c in deck_normal]],
|
||||
names=["LandPlayer", "NormalPlayer"],
|
||||
)
|
||||
for p in sim.players:
|
||||
expected = 7 - p.stats.mulligans
|
||||
assert len(p.hand) == expected, (
|
||||
f"{p.name}: hand={len(p.hand)}, mulligans={p.stats.mulligans}, "
|
||||
f"expected={expected}"
|
||||
)
|
||||
|
||||
def test_opening_hand_lands_tracked(self):
|
||||
"""opening_hand_lands should reflect final kept hand."""
|
||||
random.seed(42)
|
||||
deck = _make_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
sim = engine._create_state(
|
||||
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
|
||||
names=["P1", "P2"],
|
||||
)
|
||||
for p in sim.players:
|
||||
actual_lands = sum(1 for c in p.hand if c.is_land())
|
||||
assert p.stats.opening_hand_lands == actual_lands
|
||||
|
||||
def test_total_cards_preserved(self):
|
||||
"""Mulligan must not create or destroy cards."""
|
||||
random.seed(33)
|
||||
deck = _make_deck(lands=24, spells=36)
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
for _ in range(10):
|
||||
sim = engine._create_state(
|
||||
decks=[[c.clone() for c in deck], [c.clone() for c in deck]],
|
||||
names=["A", "B"],
|
||||
)
|
||||
for p in sim.players:
|
||||
total = len(p.hand) + len(p.library) + len(p.command_zone)
|
||||
assert total == 60, f"Card count mismatch: {total}"
|
||||
|
||||
|
||||
# ── mulligan_rule="none" (legacy) ─────────────────────────────
|
||||
|
||||
class TestNoMulliganMode:
|
||||
def test_no_mulligan_always_keeps_seven(self):
|
||||
"""With mulligan_rule='none', always get 7 cards, 0 mulligans."""
|
||||
random.seed(42)
|
||||
deck = _all_lands_deck()
|
||||
engine = GameEngine(mulligan_rule="none")
|
||||
sim = engine._create_state(
|
||||
decks=[[c.clone() for c in deck]],
|
||||
names=["P1"],
|
||||
)
|
||||
p = sim.players[0]
|
||||
assert len(p.hand) == 7
|
||||
assert p.stats.mulligans == 0
|
||||
|
||||
|
||||
# ── N-player mulligan ─────────────────────────────────────────
|
||||
|
||||
class TestNPlayerMulligan:
|
||||
def test_four_player_mulligan(self):
|
||||
"""Each player in a 4-player game resolves mulligans independently."""
|
||||
random.seed(42)
|
||||
decks = [_make_deck() for _ in range(4)]
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run_n(
|
||||
decks=[[c.clone() for c in d] for d in decks],
|
||||
names=["A", "B", "C", "D"],
|
||||
)
|
||||
assert len(result.players) == 4
|
||||
for p in result.players:
|
||||
assert p.stats.mulligans >= 0
|
||||
assert p.stats.mulligans <= GameEngine.MAX_MULLIGANS
|
||||
|
||||
|
||||
# ── Commander + mulligan interaction ──────────────────────────
|
||||
|
||||
class TestCommanderMulligan:
|
||||
def test_commander_stays_in_command_zone_through_mulligans(self):
|
||||
"""Commander should remain in command zone regardless of mulligans."""
|
||||
random.seed(42)
|
||||
# Build a deck with a known commander
|
||||
deck = _make_deck(lands=24, spells=35)
|
||||
commander = Card(name="Kenrith, the Returned King", type_line="Creature", cmc=5, pt="5/5")
|
||||
deck.append(commander)
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
sim = engine._create_state(
|
||||
decks=[[c.clone() for c in deck]],
|
||||
names=["P1"],
|
||||
commander_names=["Kenrith, the Returned King"],
|
||||
)
|
||||
p = sim.players[0]
|
||||
assert len(p.command_zone) == 1
|
||||
assert p.command_zone[0].name == "Kenrith, the Returned King"
|
||||
assert p.command_zone[0].is_commander is True
|
||||
# Commander should not appear in hand or library
|
||||
assert all(not c.is_commander for c in p.hand)
|
||||
assert all(not c.is_commander for c in p.library)
|
||||
|
||||
|
||||
# ── Serialization round-trip ──────────────────────────────────
|
||||
|
||||
class TestMulliganSerialization:
|
||||
def test_result_dict_includes_mulligans(self):
|
||||
"""to_dict() output should include mulligan stats."""
|
||||
import json
|
||||
random.seed(42)
|
||||
deck = _make_deck()
|
||||
engine = GameEngine(mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
d = result.to_dict()
|
||||
# Verify JSON-serializable
|
||||
json.dumps(d)
|
||||
for pr in d["playerResults"]:
|
||||
assert "mulligans" in pr["stats"]
|
||||
assert "openingHandLands" in pr["stats"]
|
||||
@@ -0,0 +1,628 @@
|
||||
"""
|
||||
Phase 1 regression tests — BUG-01, BUG-02, BUG-05.
|
||||
|
||||
These tests verify the critical simulation fixes:
|
||||
BUG-01: Unknown cards no longer default to Creature; combat eligibility
|
||||
requires 'Creature' in type_line.
|
||||
BUG-02: get_legal_moves() and apply_move() produce real game actions.
|
||||
BUG-05: Instants and sorceries go to graveyard after resolution, not
|
||||
battlefield.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.sim.game_state import (
|
||||
CommanderGameState,
|
||||
CommanderPlayer,
|
||||
ManaPool,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# BUG-01: Card type classification & combat eligibility
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBug01_EnrichCardNoDefaultCreature:
|
||||
"""enrich_card() must NOT turn unknown cards into Creature."""
|
||||
|
||||
def test_unknown_card_gets_unknown_type(self):
|
||||
"""Card with no Scryfall data should be 'Unknown', not 'Creature'."""
|
||||
c = Card(name="Totally Made Up Card")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Unknown"
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_unknown_card_deterministic_cmc(self):
|
||||
"""Unknown cards still get deterministic CMC from hash."""
|
||||
c = Card(name="Totally Made Up Card")
|
||||
enrich_card(c)
|
||||
assert 2 <= c.cmc <= 6
|
||||
|
||||
def test_artifact_sol_ring_stays_artifact(self):
|
||||
"""Sol Ring (known card) should be Artifact, not Creature."""
|
||||
c = Card(name="Sol Ring")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Artifact"
|
||||
assert c.is_creature() is False
|
||||
assert c.is_ramp is True
|
||||
|
||||
def test_arcane_signet_stays_artifact(self):
|
||||
c = Card(name="Arcane Signet")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Artifact"
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_sorcery_cultivate_stays_sorcery(self):
|
||||
c = Card(name="Cultivate")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Sorcery"
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_instant_murder_stays_instant(self):
|
||||
c = Card(name="Murder")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Instant"
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_real_creature_preserved(self):
|
||||
"""Card with real Scryfall Creature data should keep its type."""
|
||||
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2")
|
||||
enrich_card(c)
|
||||
assert c.is_creature() is True
|
||||
assert c.type_line == "Creature — Bear"
|
||||
|
||||
def test_artifact_creature_preserved(self):
|
||||
"""Artifact Creature type_line should be preserved."""
|
||||
c = Card(name="Ornithopter", type_line="Artifact Creature — Thopter",
|
||||
cmc=0, pt="0/2")
|
||||
enrich_card(c)
|
||||
assert c.is_creature() is True
|
||||
assert "artifact" in c.type_line.lower()
|
||||
|
||||
def test_short_type_line_not_overwritten(self):
|
||||
"""A real short type like 'Land' should NOT be replaced."""
|
||||
c = Card(name="Wastes", type_line="Land", cmc=0)
|
||||
enrich_card(c)
|
||||
# has_real_data is True because type_line is set and cmc=0 but len > 3
|
||||
assert c.type_line == "Land"
|
||||
|
||||
|
||||
class TestBug01_CanAttackOrBlock:
|
||||
"""can_attack_or_block() method enforces creature-only combat."""
|
||||
|
||||
def test_creature_can_attack(self):
|
||||
c = Card(name="Hill Giant", type_line="Creature — Giant",
|
||||
cmc=4, pt="3/3")
|
||||
assert c.can_attack_or_block() is True
|
||||
|
||||
def test_artifact_cannot_attack(self):
|
||||
c = Card(name="Sol Ring", type_line="Artifact", cmc=1)
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_enchantment_cannot_attack(self):
|
||||
c = Card(name="Rhystic Study", type_line="Enchantment", cmc=3)
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_instant_cannot_attack(self):
|
||||
c = Card(name="Lightning Bolt", type_line="Instant", cmc=1)
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_sorcery_cannot_attack(self):
|
||||
c = Card(name="Cultivate", type_line="Sorcery", cmc=3)
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_unknown_type_cannot_attack(self):
|
||||
c = Card(name="Mystery", type_line="Unknown")
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_empty_type_cannot_attack(self):
|
||||
c = Card(name="Nothing", type_line="")
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
def test_artifact_creature_can_attack(self):
|
||||
c = Card(name="Solemn Simulacrum",
|
||||
type_line="Artifact Creature — Golem",
|
||||
cmc=4, pt="2/2")
|
||||
assert c.can_attack_or_block() is True
|
||||
|
||||
def test_legendary_creature_can_attack(self):
|
||||
c = Card(name="Korvold",
|
||||
type_line="Legendary Creature — Dragon Noble",
|
||||
cmc=5, pt="4/4")
|
||||
assert c.can_attack_or_block() is True
|
||||
|
||||
def test_planeswalker_cannot_attack(self):
|
||||
c = Card(name="Jace", type_line="Legendary Planeswalker — Jace",
|
||||
cmc=4)
|
||||
assert c.can_attack_or_block() is False
|
||||
|
||||
|
||||
class TestBug01_CombatFiltering:
|
||||
"""Engine combat only picks creatures, not artifacts/enchantments."""
|
||||
|
||||
def _build_sim_with_battlefield(self, cards_p0, cards_p1=None):
|
||||
"""Build a SimState with two players and specific battlefields."""
|
||||
sim = SimState(max_turns=25)
|
||||
p0 = Player(name="Alice", life=40, owner_id=0,
|
||||
stats=PlayerStats(cards_drawn=7))
|
||||
p1 = Player(name="Bob", life=40, owner_id=1,
|
||||
stats=PlayerStats(cards_drawn=7))
|
||||
sim.players = [p0, p1]
|
||||
sim.init_battlefields(2)
|
||||
cid = 90000
|
||||
for c in cards_p0:
|
||||
c.id = cid
|
||||
c.owner_id = 0
|
||||
c.tapped = False
|
||||
c.turn_played = 0 # played on turn 0, so eligible on turn 1+
|
||||
cid += 1
|
||||
sim.add_to_battlefield(0, c)
|
||||
for c in (cards_p1 or []):
|
||||
c.id = cid
|
||||
c.owner_id = 1
|
||||
c.tapped = False
|
||||
c.turn_played = 0
|
||||
cid += 1
|
||||
sim.add_to_battlefield(1, c)
|
||||
sim.next_card_id = cid
|
||||
return sim
|
||||
|
||||
def test_artifact_does_not_attack(self):
|
||||
"""Sol Ring on battlefield should not be selected as an attacker."""
|
||||
creature = Card(name="Hill Giant", type_line="Creature — Giant",
|
||||
cmc=4, pt="3/3")
|
||||
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
|
||||
sim = self._build_sim_with_battlefield([creature, artifact])
|
||||
sim.turn = 1
|
||||
|
||||
engine = GameEngine()
|
||||
events = []
|
||||
engine._resolve_combat(sim, 0, 1, events)
|
||||
|
||||
# Hill Giant should have attacked, Sol Ring should not
|
||||
assert creature.tapped is True # attacked
|
||||
assert artifact.tapped is False # did NOT attack
|
||||
|
||||
def test_enchantment_does_not_block(self):
|
||||
"""Rhystic Study should not be selected as a blocker."""
|
||||
attacker = Card(name="Hill Giant", type_line="Creature — Giant",
|
||||
cmc=4, pt="3/3")
|
||||
enchantment = Card(name="Rhystic Study", type_line="Enchantment",
|
||||
cmc=3)
|
||||
blocker_creature = Card(name="Wall of Stone",
|
||||
type_line="Creature — Wall",
|
||||
cmc=3, pt="0/8")
|
||||
|
||||
sim = self._build_sim_with_battlefield(
|
||||
[attacker],
|
||||
[enchantment, blocker_creature],
|
||||
)
|
||||
sim.turn = 1
|
||||
engine = GameEngine()
|
||||
events = []
|
||||
engine._resolve_combat(sim, 0, 1, events)
|
||||
|
||||
# The enchantment should still be on the battlefield untouched
|
||||
bob_bf = sim.get_battlefield(1)
|
||||
enchantments = [c for c in bob_bf if c.name == "Rhystic Study"]
|
||||
assert len(enchantments) == 1
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# BUG-02: get_legal_moves() and apply_move() implementation
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBug02_GetLegalMoves:
|
||||
"""get_legal_moves() returns actual playable actions, not just pass."""
|
||||
|
||||
def _make_cgs(self, hand=None, bf=None, phase="main1",
|
||||
land_drop_used=False):
|
||||
"""Build a CommanderGameState for testing."""
|
||||
sim = SimState(max_turns=25)
|
||||
p = Player(name="Alice", life=40, owner_id=0,
|
||||
hand=hand or [], stats=PlayerStats(cards_drawn=7))
|
||||
sim.players = [p]
|
||||
sim.init_battlefields(1)
|
||||
cid = 90000
|
||||
for c in (bf or []):
|
||||
c.id = cid
|
||||
c.owner_id = 0
|
||||
c.tapped = False
|
||||
cid += 1
|
||||
sim.add_to_battlefield(0, c)
|
||||
sim.next_card_id = cid
|
||||
|
||||
cgs = CommanderGameState.from_sim_state(sim)
|
||||
cgs.current_phase = phase
|
||||
cgs.land_drop_used = land_drop_used
|
||||
cgs.turn = 1
|
||||
return cgs
|
||||
|
||||
def test_pass_always_available(self):
|
||||
cgs = self._make_cgs()
|
||||
moves = cgs.get_legal_moves(0)
|
||||
pass_moves = [m for m in moves if m["category"] == "pass_priority"]
|
||||
assert len(pass_moves) >= 1
|
||||
|
||||
def test_land_in_hand_during_main_phase(self):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
||||
cgs = self._make_cgs(hand=[land], phase="main1")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
land_moves = [m for m in moves if m["category"] == "play_land"]
|
||||
assert len(land_moves) == 1
|
||||
assert "Forest" in land_moves[0]["description"]
|
||||
|
||||
def test_no_land_play_when_drop_used(self):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
||||
cgs = self._make_cgs(hand=[land], phase="main1", land_drop_used=True)
|
||||
moves = cgs.get_legal_moves(0)
|
||||
land_moves = [m for m in moves if m["category"] == "play_land"]
|
||||
assert len(land_moves) == 0
|
||||
|
||||
def test_castable_spell_with_mana(self):
|
||||
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2")
|
||||
lands = [
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
]
|
||||
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
|
||||
assert len(cast_moves) == 1
|
||||
assert "Grizzly Bears" in cast_moves[0]["description"]
|
||||
|
||||
def test_spell_too_expensive(self):
|
||||
spell = Card(name="Big Creature", type_line="Creature", cmc=5,
|
||||
pt="5/5")
|
||||
lands = [Card(name="Forest", type_line="Basic Land", cmc=0)]
|
||||
cgs = self._make_cgs(hand=[spell], bf=lands, phase="main1")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
|
||||
assert len(cast_moves) == 0
|
||||
|
||||
def test_no_spells_during_combat(self):
|
||||
spell = Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2")
|
||||
lands = [
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
]
|
||||
cgs = self._make_cgs(hand=[spell], bf=lands,
|
||||
phase="declare_attackers")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
cast_moves = [m for m in moves if m["category"] == "cast_spell"]
|
||||
assert len(cast_moves) == 0
|
||||
|
||||
def test_attack_move_with_creatures(self):
|
||||
creature = Card(name="Hill Giant", type_line="Creature — Giant",
|
||||
cmc=4, pt="3/3", turn_played=0)
|
||||
cgs = self._make_cgs(bf=[creature], phase="declare_attackers")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
atk_moves = [m for m in moves if m["category"] == "attack"]
|
||||
assert len(atk_moves) == 1
|
||||
|
||||
def test_no_attack_without_creatures(self):
|
||||
artifact = Card(name="Sol Ring", type_line="Artifact", cmc=1)
|
||||
cgs = self._make_cgs(bf=[artifact], phase="declare_attackers")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
atk_moves = [m for m in moves if m["category"] == "attack"]
|
||||
assert len(atk_moves) == 0
|
||||
|
||||
def test_instant_available_during_response_phase(self):
|
||||
instant = Card(name="Swords to Plowshares", type_line="Instant",
|
||||
cmc=1, oracle_text="Exile target creature.")
|
||||
lands = [Card(name="Plains", type_line="Basic Land — Plains", cmc=0)]
|
||||
cgs = self._make_cgs(hand=[instant], bf=lands, phase="end_step")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
instant_moves = [m for m in moves if m["category"] == "instant"]
|
||||
assert len(instant_moves) == 1
|
||||
|
||||
|
||||
class TestBug02_ApplyMove:
|
||||
"""apply_move() properly executes game actions."""
|
||||
|
||||
def _make_cgs(self, hand=None, bf=None, phase="main1",
|
||||
land_drop_used=False):
|
||||
sim = SimState(max_turns=25)
|
||||
p = Player(name="Alice", life=40, owner_id=0,
|
||||
hand=hand or [], stats=PlayerStats(cards_drawn=7))
|
||||
sim.players = [p]
|
||||
sim.init_battlefields(1)
|
||||
cid = 90000
|
||||
for c in (bf or []):
|
||||
c.id = cid
|
||||
c.owner_id = 0
|
||||
c.tapped = False
|
||||
cid += 1
|
||||
sim.add_to_battlefield(0, c)
|
||||
sim.next_card_id = cid
|
||||
|
||||
cgs = CommanderGameState.from_sim_state(sim)
|
||||
cgs.current_phase = phase
|
||||
cgs.land_drop_used = land_drop_used
|
||||
cgs.turn = 1
|
||||
return cgs
|
||||
|
||||
def test_play_land_moves_to_battlefield(self):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
||||
cgs = self._make_cgs(hand=[land], phase="main1")
|
||||
|
||||
moves = cgs.get_legal_moves(0)
|
||||
land_move = next(m for m in moves if m["category"] == "play_land")
|
||||
cgs.apply_move(0, land_move["id"])
|
||||
|
||||
bf = cgs.sim_state.get_battlefield(0)
|
||||
assert any(c.name == "Forest" for c in bf)
|
||||
assert len(cgs.commander_players[0].base.hand) == 0
|
||||
assert cgs.land_drop_used is True
|
||||
|
||||
def test_cast_creature_to_battlefield(self):
|
||||
creature = Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2")
|
||||
lands = [
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
]
|
||||
cgs = self._make_cgs(hand=[creature], bf=lands, phase="main1")
|
||||
|
||||
moves = cgs.get_legal_moves(0)
|
||||
cast_move = next(m for m in moves if m["category"] == "cast_spell")
|
||||
cgs.apply_move(0, cast_move["id"])
|
||||
|
||||
bf = cgs.sim_state.get_battlefield(0)
|
||||
creature_on_bf = [c for c in bf if c.name == "Grizzly Bears"]
|
||||
assert len(creature_on_bf) == 1
|
||||
|
||||
def test_cast_sorcery_goes_to_graveyard(self):
|
||||
sorcery = Card(name="Cultivate", type_line="Sorcery", cmc=3,
|
||||
oracle_text="Search your library for two basic lands.")
|
||||
lands = [
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
Card(name="Forest", type_line="Basic Land — Forest", cmc=0),
|
||||
]
|
||||
cgs = self._make_cgs(hand=[sorcery], bf=lands, phase="main1")
|
||||
|
||||
moves = cgs.get_legal_moves(0)
|
||||
cast_move = next(m for m in moves if m["category"] == "cast_spell")
|
||||
cgs.apply_move(0, cast_move["id"])
|
||||
|
||||
bf = cgs.sim_state.get_battlefield(0)
|
||||
sorceries_on_bf = [c for c in bf if c.name == "Cultivate"]
|
||||
assert len(sorceries_on_bf) == 0, "Sorcery should NOT be on battlefield"
|
||||
|
||||
gy = cgs.commander_players[0].base.graveyard
|
||||
assert any(c.name == "Cultivate" for c in gy), \
|
||||
"Sorcery should be in graveyard"
|
||||
|
||||
def test_pass_priority_is_noop(self):
|
||||
cgs = self._make_cgs(phase="main1")
|
||||
moves = cgs.get_legal_moves(0)
|
||||
pass_move = next(m for m in moves if m["category"] == "pass_priority")
|
||||
# Should not raise
|
||||
cgs.apply_move(0, pass_move["id"])
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# BUG-05: Instants/sorceries should not remain on the battlefield
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBug05_SpellResolution:
|
||||
"""Instants and sorceries are routed to graveyard, not battlefield."""
|
||||
|
||||
def test_is_permanent_creature(self):
|
||||
c = Card(name="Bear", type_line="Creature — Bear")
|
||||
assert c.is_permanent() is True
|
||||
|
||||
def test_is_permanent_artifact(self):
|
||||
c = Card(name="Sol Ring", type_line="Artifact")
|
||||
assert c.is_permanent() is True
|
||||
|
||||
def test_is_permanent_enchantment(self):
|
||||
c = Card(name="Rhystic Study", type_line="Enchantment")
|
||||
assert c.is_permanent() is True
|
||||
|
||||
def test_is_not_permanent_instant(self):
|
||||
c = Card(name="Lightning Bolt", type_line="Instant")
|
||||
assert c.is_permanent() is False
|
||||
|
||||
def test_is_not_permanent_sorcery(self):
|
||||
c = Card(name="Cultivate", type_line="Sorcery")
|
||||
assert c.is_permanent() is False
|
||||
|
||||
def test_engine_sorcery_not_on_battlefield(self):
|
||||
"""A non-removal, non-wipe sorcery cast via engine goes to graveyard."""
|
||||
# Build a deck with a draw sorcery (Harmonize) and lots of lands
|
||||
harmonize = Card(name="Harmonize")
|
||||
enrich_card(harmonize)
|
||||
assert harmonize.type_line == "Sorcery"
|
||||
|
||||
# Build mini sim state
|
||||
sim = SimState(max_turns=25)
|
||||
p0 = Player(name="Alice", life=40, owner_id=0,
|
||||
hand=[harmonize],
|
||||
stats=PlayerStats(cards_drawn=7))
|
||||
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
|
||||
stats=PlayerStats(cards_drawn=7))]
|
||||
sim.init_battlefields(2)
|
||||
|
||||
# Give Alice 5 untapped lands on battlefield
|
||||
cid = 90000
|
||||
for i in range(5):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest",
|
||||
cmc=0, id=cid, owner_id=0, tapped=False)
|
||||
cid += 1
|
||||
sim.add_to_battlefield(0, land)
|
||||
sim.next_card_id = cid
|
||||
|
||||
engine = GameEngine()
|
||||
engine._play_spells(sim, 0, 5)
|
||||
|
||||
# Harmonize should NOT be on the battlefield
|
||||
bf = sim.get_battlefield(0)
|
||||
sorceries = [c for c in bf if c.name == "Harmonize"]
|
||||
assert len(sorceries) == 0, "Sorcery should not stay on battlefield"
|
||||
|
||||
# It should be in the graveyard
|
||||
assert any(c.name == "Harmonize" for c in p0.graveyard), \
|
||||
"Sorcery should be in graveyard after resolution"
|
||||
|
||||
def test_engine_artifact_stays_on_battlefield(self):
|
||||
"""An artifact (permanent) should stay on battlefield after being cast."""
|
||||
sol_ring = Card(name="Sol Ring")
|
||||
enrich_card(sol_ring)
|
||||
|
||||
sim = SimState(max_turns=25)
|
||||
p0 = Player(name="Alice", life=40, owner_id=0,
|
||||
hand=[sol_ring],
|
||||
stats=PlayerStats(cards_drawn=7))
|
||||
sim.players = [p0, Player(name="Bob", life=40, owner_id=1,
|
||||
stats=PlayerStats(cards_drawn=7))]
|
||||
sim.init_battlefields(2)
|
||||
|
||||
cid = 90000
|
||||
for i in range(2):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest",
|
||||
cmc=0, id=cid, owner_id=0, tapped=False)
|
||||
cid += 1
|
||||
sim.add_to_battlefield(0, land)
|
||||
sim.next_card_id = cid
|
||||
|
||||
engine = GameEngine()
|
||||
engine._play_spells(sim, 0, 2)
|
||||
|
||||
bf = sim.get_battlefield(0)
|
||||
artifacts = [c for c in bf if c.name == "Sol Ring"]
|
||||
assert len(artifacts) == 1, "Artifact should remain on battlefield"
|
||||
|
||||
|
||||
class TestBug05_IsInstantAndSorcery:
|
||||
"""Card.is_instant() and is_sorcery() helpers."""
|
||||
|
||||
def test_instant_detected(self):
|
||||
c = Card(name="Bolt", type_line="Instant")
|
||||
assert c.is_instant() is True
|
||||
assert c.is_sorcery() is False
|
||||
|
||||
def test_sorcery_detected(self):
|
||||
c = Card(name="Wrath", type_line="Sorcery")
|
||||
assert c.is_sorcery() is True
|
||||
assert c.is_instant() is False
|
||||
|
||||
def test_creature_not_instant_or_sorcery(self):
|
||||
c = Card(name="Bear", type_line="Creature — Bear")
|
||||
assert c.is_instant() is False
|
||||
assert c.is_sorcery() is False
|
||||
|
||||
def test_empty_type_line(self):
|
||||
c = Card(name="?")
|
||||
assert c.is_instant() is False
|
||||
assert c.is_sorcery() is False
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Integration: full game still runs with all fixes applied
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Sanity check that a full game can still run with all fixes."""
|
||||
|
||||
def _make_deck(self, size=60):
|
||||
"""Build a test deck with mixed card types."""
|
||||
cards = []
|
||||
# 24 lands
|
||||
for _ in range(24):
|
||||
c = Card(name="Forest")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 15 creatures (with real type data)
|
||||
for _ in range(15):
|
||||
c = Card(name="Grizzly Bears", type_line="Creature — Bear",
|
||||
cmc=2, pt="2/2")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 5 removal instants
|
||||
for _ in range(5):
|
||||
c = Card(name="Murder")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 3 board wipes
|
||||
for _ in range(3):
|
||||
c = Card(name="Wrath of God")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 5 artifacts (ramp)
|
||||
for _ in range(5):
|
||||
c = Card(name="Sol Ring")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 3 ramp sorceries
|
||||
for _ in range(3):
|
||||
c = Card(name="Cultivate")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
# 5 unknown cards (should be "Unknown" type, not creatures)
|
||||
for i in range(5):
|
||||
c = Card(name=f"Mystery Card {i}")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
return cards
|
||||
|
||||
def test_full_game_completes(self):
|
||||
"""A 2-player game with mixed deck should complete without errors."""
|
||||
engine = GameEngine(max_turns=25)
|
||||
deck_a = self._make_deck()
|
||||
deck_b = self._make_deck()
|
||||
result = engine.run(
|
||||
[c.clone() for c in deck_a],
|
||||
[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_no_artifacts_in_combat_log(self):
|
||||
"""With record_log, no artifact names should appear in attack events."""
|
||||
engine = GameEngine(max_turns=10, record_log=True)
|
||||
deck_a = self._make_deck()
|
||||
deck_b = self._make_deck()
|
||||
result = engine.run(
|
||||
[c.clone() for c in deck_a],
|
||||
[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
for turn_entry in result.game_log:
|
||||
for phase in turn_entry.get("phases", []):
|
||||
for event in phase.get("events", []):
|
||||
if "Attacks" in event:
|
||||
assert "Sol Ring" not in event, \
|
||||
f"Sol Ring should not attack: {event}"
|
||||
|
||||
def test_unknown_cards_not_in_combat(self):
|
||||
"""Unknown-type cards should not appear in attack event logs."""
|
||||
engine = GameEngine(max_turns=10, record_log=True)
|
||||
deck_a = self._make_deck()
|
||||
deck_b = self._make_deck()
|
||||
result = engine.run(
|
||||
[c.clone() for c in deck_a],
|
||||
[c.clone() for c in deck_b],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
for turn_entry in result.game_log:
|
||||
for phase in turn_entry.get("phases", []):
|
||||
for event in phase.get("events", []):
|
||||
if "Attacks" in event:
|
||||
assert "Mystery Card" not in event, \
|
||||
f"Unknown card should not attack: {event}"
|
||||
@@ -67,10 +67,17 @@ class TestEnrichCardDeterministic:
|
||||
c2 = Card(name="Zzzzz")
|
||||
enrich_card(c1)
|
||||
enrich_card(c2)
|
||||
# At minimum they are both valid creatures with cmc in [2,6]
|
||||
# At minimum they are both valid Unknown-type cards with cmc in [2,6]
|
||||
assert 2 <= c1.cmc <= 6
|
||||
assert 2 <= c2.cmc <= 6
|
||||
|
||||
def test_unknown_card_type_is_unknown(self):
|
||||
"""Unknown cards should get type_line='Unknown', not 'Creature'."""
|
||||
c = Card(name="Totally Unknown Card XYZ")
|
||||
enrich_card(c)
|
||||
assert c.type_line == "Unknown"
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_unknown_card_cmc_range(self):
|
||||
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
|
||||
c = Card(name=name)
|
||||
|
||||
@@ -1,515 +0,0 @@
|
||||
"""Regression tests for explicit turn-structure and phase progression.
|
||||
|
||||
Validates that the headless engine walks through all MTG phases in order,
|
||||
tracks the current phase on SimState, supports a shared spell budget across
|
||||
main1+main2, and preserves all prior behavior (combat, elimination, logging).
|
||||
"""
|
||||
|
||||
import random
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import (
|
||||
Card,
|
||||
Phase,
|
||||
PHASE_ORDER,
|
||||
SORCERY_PHASES,
|
||||
SimState,
|
||||
)
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
||||
"""Deterministic test deck."""
|
||||
rng = random.Random(seed)
|
||||
pool = [
|
||||
Card(name="Forest"),
|
||||
Card(name="Sol Ring"),
|
||||
Card(name="Cultivate"),
|
||||
Card(name="Murder"),
|
||||
Card(name="Wrath of God"),
|
||||
Card(name="Grizzly Bears"),
|
||||
Card(name="Serra Angel"),
|
||||
]
|
||||
for c in pool:
|
||||
enrich_card(c)
|
||||
deck = [pool[i % len(pool)].clone() for i in range(size)]
|
||||
rng.shuffle(deck)
|
||||
return deck
|
||||
|
||||
|
||||
def _land_heavy_deck(size: int = 60) -> list[Card]:
|
||||
"""Deck with many lands (ensures mana for spells in both main phases)."""
|
||||
cards = []
|
||||
for i in range(size):
|
||||
if i < 30:
|
||||
c = Card(name="Forest")
|
||||
elif i < 40:
|
||||
c = Card(name="Grizzly Bears")
|
||||
elif i < 50:
|
||||
c = Card(name="Serra Angel")
|
||||
else:
|
||||
c = Card(name="Sol Ring")
|
||||
enrich_card(c)
|
||||
cards.append(c)
|
||||
random.Random(42).shuffle(cards)
|
||||
return cards
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase Enum Tests
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPhaseEnum:
|
||||
"""Tests for the Phase enum and related constants."""
|
||||
|
||||
def test_phase_count(self):
|
||||
"""There should be exactly 12 phases in MTG turn structure."""
|
||||
assert len(Phase) == 12
|
||||
|
||||
def test_phase_order_matches_mtg(self):
|
||||
"""Phases must follow the MTG turn order."""
|
||||
expected = [
|
||||
"untap", "upkeep", "draw", "main1",
|
||||
"begin_combat", "declare_attackers", "declare_blockers",
|
||||
"combat_damage", "end_combat",
|
||||
"main2", "end_step", "cleanup",
|
||||
]
|
||||
assert [p.value for p in PHASE_ORDER] == expected
|
||||
|
||||
def test_phase_is_str_subclass(self):
|
||||
"""Phase enum values should compare directly with plain strings."""
|
||||
assert Phase.MAIN1 == "main1"
|
||||
assert Phase.CLEANUP == "cleanup"
|
||||
|
||||
def test_sorcery_phases(self):
|
||||
"""Only main1 and main2 are sorcery-speed phases."""
|
||||
assert SORCERY_PHASES == frozenset({Phase.MAIN1, Phase.MAIN2})
|
||||
|
||||
def test_phase_iteration(self):
|
||||
"""PHASE_ORDER is iterable and matches Phase members."""
|
||||
assert list(PHASE_ORDER) == list(Phase)
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# SimState Phase Tracking
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSimStatePhaseTracking:
|
||||
"""Tests that SimState tracks phase and active player index."""
|
||||
|
||||
def test_default_phase(self):
|
||||
"""SimState defaults to MAIN1."""
|
||||
sim = SimState()
|
||||
assert sim.current_phase == Phase.MAIN1
|
||||
|
||||
def test_default_active_player(self):
|
||||
"""SimState defaults to player 0 as active."""
|
||||
sim = SimState()
|
||||
assert sim.active_player_index == 0
|
||||
|
||||
def test_phase_updates_during_game(self):
|
||||
"""After a game, current_phase should have been set (not stuck at default)."""
|
||||
engine = GameEngine(max_turns=1, mulligan_rule="none")
|
||||
deck_a = _make_deck(seed=10)
|
||||
deck_b = _make_deck(seed=11)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
)
|
||||
# Game completed successfully
|
||||
assert result.turns >= 1
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Phase Progression in Engine
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestPhaseProgression:
|
||||
"""Tests that the engine walks through phases correctly."""
|
||||
|
||||
def test_game_completes_with_phase_structure(self):
|
||||
"""Game should complete without errors using the new phase loop."""
|
||||
engine = GameEngine(max_turns=10)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in _make_deck(1)],
|
||||
deck_b=[c.clone() for c in _make_deck(2)],
|
||||
name_a="Alice",
|
||||
name_b="Bob",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_four_player_game_with_phases(self):
|
||||
"""4-player game should complete with the new phase loop."""
|
||||
engine = GameEngine(max_turns=10)
|
||||
decks = [_make_deck(seed=i) for i in range(4)]
|
||||
result = engine.run_n(
|
||||
decks=[[c.clone() for c in d] for d in decks],
|
||||
names=["A", "B", "C", "D"],
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
assert len(result.players) == 4
|
||||
|
||||
def test_untap_happens_each_turn(self):
|
||||
"""Creatures tapped for combat are untapped at start of next turn."""
|
||||
engine = GameEngine(max_turns=5, record_log=True, mulligan_rule="none")
|
||||
# Build deck with creatures that will attack
|
||||
deck = []
|
||||
for i in range(60):
|
||||
if i < 25:
|
||||
deck.append(Card(name="Forest"))
|
||||
else:
|
||||
c = Card(name="Grizzly Bears")
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
# Game should complete — untap logic is working
|
||||
assert result.turns > 0
|
||||
|
||||
def test_draw_happens_after_untap(self):
|
||||
"""Players draw one card each turn after the first."""
|
||||
engine = GameEngine(max_turns=3, mulligan_rule="none")
|
||||
deck_a = _make_deck(seed=20, size=60)
|
||||
deck_b = _make_deck(seed=21, size=60)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
)
|
||||
# Both players should have drawn cards (starting 7 + draws)
|
||||
for pr in result.players:
|
||||
assert pr.stats.cards_drawn >= 7
|
||||
|
||||
def test_land_drop_in_main1(self):
|
||||
"""Land drops should still happen (in main1)."""
|
||||
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
||||
deck = _make_deck(seed=30)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
# At least one player should have played lands
|
||||
total_lands = sum(pr.stats.lands_played for pr in result.players)
|
||||
assert total_lands > 0
|
||||
|
||||
def test_combat_in_declare_attackers_phase(self):
|
||||
"""Combat damage should still resolve correctly."""
|
||||
engine = GameEngine(max_turns=25, mulligan_rule="none")
|
||||
# Use creature-heavy deck so combat is guaranteed
|
||||
deck = []
|
||||
for i in range(60):
|
||||
if i < 24:
|
||||
deck.append(Card(name="Forest"))
|
||||
else:
|
||||
c = Card(name="Grizzly Bears")
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
random.Random(40).shuffle(deck)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
total_damage = sum(pr.stats.damage_dealt for pr in result.players)
|
||||
# With creature-heavy decks over 25 turns, combat must happen
|
||||
assert total_damage > 0
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Spell Budget Across Main Phases
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestSpellBudget:
|
||||
"""Tests that the 2-spell budget is shared across main1 and main2."""
|
||||
|
||||
def test_max_two_spells_per_turn(self):
|
||||
"""No player should cast more than 2 non-commander spells per turn."""
|
||||
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
||||
deck = _land_heavy_deck()
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
# Both players played spells
|
||||
for pr in result.players:
|
||||
# spells_cast across all turns; with 5 turns max 10 spells each
|
||||
assert pr.stats.spells_cast <= 10 # 2 per turn * 5 turns
|
||||
|
||||
def test_spells_can_split_across_main_phases(self):
|
||||
"""Engine supports casting in main2 if budget allows.
|
||||
|
||||
We verify by checking that _play_spells returns 0 when budget is 0.
|
||||
"""
|
||||
engine = GameEngine()
|
||||
# Create a minimal sim state with one player
|
||||
sim = SimState(max_turns=1)
|
||||
from commander_ai_lab.sim.models import Player, PlayerStats
|
||||
p = Player(
|
||||
name="Test",
|
||||
life=40,
|
||||
owner_id=0,
|
||||
hand=[],
|
||||
stats=PlayerStats(),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(1)
|
||||
|
||||
# With no cards in hand, should play 0 spells
|
||||
result = engine._play_spells(sim, 0, available_mana=5, max_spells=2)
|
||||
assert result == 0
|
||||
|
||||
def test_play_spells_respects_max_spells_zero(self):
|
||||
"""_play_spells with max_spells=0 plays nothing."""
|
||||
engine = GameEngine()
|
||||
sim = SimState(max_turns=1)
|
||||
from commander_ai_lab.sim.models import Player, PlayerStats
|
||||
p = Player(
|
||||
name="Test",
|
||||
life=40,
|
||||
owner_id=0,
|
||||
hand=[Card(name="Grizzly Bears", cmc=2, type_line="Creature")],
|
||||
stats=PlayerStats(),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(1)
|
||||
# Add 5 untapped lands
|
||||
for _ in range(5):
|
||||
land = Card(name="Forest", type_line="Land", tapped=False)
|
||||
sim.add_to_battlefield(0, land)
|
||||
|
||||
result = engine._play_spells(sim, 0, available_mana=5, max_spells=0)
|
||||
assert result == 0
|
||||
# Card should still be in hand
|
||||
assert len(p.hand) == 1
|
||||
|
||||
def test_play_spells_respects_max_spells_one(self):
|
||||
"""_play_spells with max_spells=1 plays at most one spell."""
|
||||
engine = GameEngine()
|
||||
sim = SimState(max_turns=1)
|
||||
from commander_ai_lab.sim.models import Player, PlayerStats
|
||||
bear1 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
|
||||
bear2 = Card(name="Grizzly Bears", cmc=2, type_line="Creature — Bear", pt="2/2")
|
||||
enrich_card(bear1)
|
||||
enrich_card(bear2)
|
||||
p = Player(
|
||||
name="Test",
|
||||
life=40,
|
||||
owner_id=0,
|
||||
hand=[bear1, bear2],
|
||||
stats=PlayerStats(),
|
||||
)
|
||||
sim.players.append(p)
|
||||
sim.init_battlefields(1)
|
||||
for _ in range(5):
|
||||
land = Card(name="Forest", type_line="Land", tapped=False)
|
||||
sim.add_to_battlefield(0, land)
|
||||
|
||||
result = engine._play_spells(sim, 0, available_mana=5, max_spells=1)
|
||||
assert result == 1
|
||||
assert len(p.hand) == 1 # one spell remaining
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Game Log with Phase Structure
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestGameLogPhases:
|
||||
"""Tests that the game log still works with the new phase structure."""
|
||||
|
||||
def test_game_log_has_turns(self):
|
||||
"""record_log should produce a log with turn entries."""
|
||||
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
|
||||
deck = _make_deck(seed=50)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
assert result.game_log is not None
|
||||
assert len(result.game_log) > 0
|
||||
|
||||
def test_game_log_turn_structure(self):
|
||||
"""Each log entry should have turn number and phases list."""
|
||||
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
|
||||
deck = _make_deck(seed=51)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
for entry in result.game_log:
|
||||
assert "turn" in entry
|
||||
assert "phases" in entry
|
||||
assert isinstance(entry["phases"], list)
|
||||
|
||||
def test_game_log_phase_entries(self):
|
||||
"""Phase entries should have player and events."""
|
||||
engine = GameEngine(max_turns=2, record_log=True, mulligan_rule="none")
|
||||
deck = _make_deck(seed=52)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
for entry in result.game_log:
|
||||
for phase in entry["phases"]:
|
||||
assert "player" in phase
|
||||
assert "playerId" in phase
|
||||
assert "events" in phase
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Backward Compatibility
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestBackwardCompat:
|
||||
"""Ensure existing behavior is preserved."""
|
||||
|
||||
def test_two_player_still_works(self):
|
||||
"""The 2-player run() entry point should still work."""
|
||||
engine = GameEngine(max_turns=10)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in _make_deck(60)],
|
||||
deck_b=[c.clone() for c in _make_deck(61)],
|
||||
)
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_n_player_still_works(self):
|
||||
"""The N-player run_n() entry point should still work."""
|
||||
engine = GameEngine(max_turns=10)
|
||||
decks = [_make_deck(seed=i + 70) for i in range(3)]
|
||||
result = engine.run_n(
|
||||
decks=[[c.clone() for c in d] for d in decks],
|
||||
names=["X", "Y", "Z"],
|
||||
)
|
||||
assert result.winner_seat in range(3)
|
||||
assert len(result.players) == 3
|
||||
|
||||
def test_mulligan_rule_still_works(self):
|
||||
"""London mulligan should still function with the phase refactor."""
|
||||
engine = GameEngine(max_turns=5, mulligan_rule="london")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in _make_deck(80)],
|
||||
deck_b=[c.clone() for c in _make_deck(81)],
|
||||
)
|
||||
assert result.turns > 0
|
||||
|
||||
def test_no_mulligan_rule_still_works(self):
|
||||
"""Legacy mulligan='none' should still function."""
|
||||
engine = GameEngine(max_turns=5, mulligan_rule="none")
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in _make_deck(90)],
|
||||
deck_b=[c.clone() for c in _make_deck(91)],
|
||||
)
|
||||
assert result.turns > 0
|
||||
|
||||
def test_commander_damage_still_tracked(self):
|
||||
"""Commander damage tracking should still work through deal_damage."""
|
||||
sim = SimState(max_turns=1)
|
||||
from commander_ai_lab.sim.models import Player, PlayerStats
|
||||
for i in range(2):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
|
||||
))
|
||||
sim.init_battlefields(2)
|
||||
|
||||
commander = Card(name="TestCommander", is_commander=True)
|
||||
GameEngine.deal_damage(
|
||||
sim, 5, target_seat=1,
|
||||
source_card=commander, source_seat=0,
|
||||
is_combat=True,
|
||||
)
|
||||
assert sim.players[1].commander_damage_received[0] == 5
|
||||
assert sim.players[1].commander_damage_by_card[(0, "TestCommander")] == 5
|
||||
assert sim.players[1].life == 35
|
||||
|
||||
def test_direct_damage_still_works(self):
|
||||
"""Burn spells should still route through deal_damage."""
|
||||
sim = SimState(max_turns=1)
|
||||
from commander_ai_lab.sim.models import Player, PlayerStats
|
||||
for i in range(2):
|
||||
sim.players.append(Player(
|
||||
name=f"P{i}", life=40, owner_id=i, stats=PlayerStats(),
|
||||
))
|
||||
sim.init_battlefields(2)
|
||||
|
||||
burn = Card(name="Lightning Bolt", is_direct_damage=True, direct_damage_amount=3)
|
||||
events: list[str] = []
|
||||
GameEngine.deal_damage(
|
||||
sim, 3, target_seat=1,
|
||||
source_card=burn, source_seat=0,
|
||||
events=events,
|
||||
label="Lightning Bolt deals 3",
|
||||
)
|
||||
assert sim.players[1].life == 37
|
||||
assert len(events) == 1
|
||||
|
||||
def test_elimination_still_works(self):
|
||||
"""Player elimination should still trigger when life <= 0."""
|
||||
engine = GameEngine(max_turns=25, mulligan_rule="none")
|
||||
# Build aggro-heavy decks
|
||||
deck = []
|
||||
for i in range(60):
|
||||
if i < 20:
|
||||
deck.append(Card(name="Mountain"))
|
||||
else:
|
||||
c = Card(name="Lightning Bolt")
|
||||
enrich_card(c)
|
||||
deck.append(c)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
# At least one player should be eliminated (or game timed out)
|
||||
assert result.turns > 0
|
||||
|
||||
def test_ml_log_still_works(self):
|
||||
"""ML logging should still capture decisions."""
|
||||
engine = GameEngine(max_turns=3, ml_log=True, mulligan_rule="none")
|
||||
deck = _make_deck(seed=100)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
decisions = engine.flush_ml_decisions()
|
||||
assert isinstance(decisions, list)
|
||||
# With turns happening, there should be some decisions
|
||||
if result.turns > 0:
|
||||
assert len(decisions) > 0
|
||||
|
||||
def test_result_serializable(self):
|
||||
"""Game result should still serialize cleanly."""
|
||||
import json
|
||||
engine = GameEngine(max_turns=3, record_log=True, mulligan_rule="none")
|
||||
deck = _make_deck(seed=110)
|
||||
result = engine.run(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
)
|
||||
d = result.to_dict()
|
||||
json.dumps(d) # should not raise
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Turn Manager Phase Constants
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
class TestTurnManagerPhaseCompat:
|
||||
"""Ensure turn_manager.py PHASES/ACTION_PHASES/RESPONSE_PHASES are consistent."""
|
||||
|
||||
def test_turn_manager_phases_match_model(self):
|
||||
"""turn_manager.PHASES should match the canonical PHASE_ORDER."""
|
||||
from commander_ai_lab.sim.turn_manager import PHASES as TM_PHASES
|
||||
assert TM_PHASES == [p.value for p in PHASE_ORDER]
|
||||
|
||||
def test_turn_manager_action_phases(self):
|
||||
"""ACTION_PHASES should be main1 and main2."""
|
||||
from commander_ai_lab.sim.turn_manager import ACTION_PHASES
|
||||
assert ACTION_PHASES == {"main1", "main2"}
|
||||
|
||||
def test_turn_manager_response_phases(self):
|
||||
"""RESPONSE_PHASES should be upkeep, begin_combat, end_combat, end_step."""
|
||||
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
|
||||
assert RESPONSE_PHASES == {"upkeep", "begin_combat", "end_combat", "end_step"}
|
||||
+11
-22
@@ -48,20 +48,10 @@ class _GS:
|
||||
self.turn = 1
|
||||
self.current_phase = "main1"
|
||||
self.active_player_seat = 0
|
||||
self.priority_seat = 0
|
||||
self.stack = []
|
||||
self.game_over = False
|
||||
self.winner = None
|
||||
|
||||
def battlefield(self, seat: int) -> list:
|
||||
return self.players[seat].battlefield
|
||||
|
||||
def stack_is_empty(self) -> bool:
|
||||
return len(self.stack) == 0
|
||||
|
||||
def living_players(self) -> list:
|
||||
return [p for p in self.players if not p.eliminated]
|
||||
|
||||
def get_legal_moves(self, seat):
|
||||
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
|
||||
|
||||
@@ -70,7 +60,7 @@ class _GS:
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -82,13 +72,14 @@ class BenchThreatAssessor:
|
||||
MIN_OPS_PER_SEC = 500
|
||||
|
||||
def test_assess_threats_throughput(self):
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
gs = _GS()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(self.ITERATIONS):
|
||||
for seat in range(4):
|
||||
assess_threats(gs, viewer_seat=seat)
|
||||
ta.assess_threats(viewer_seat=seat)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
ops = self.ITERATIONS * 4
|
||||
@@ -99,12 +90,13 @@ class BenchThreatAssessor:
|
||||
)
|
||||
|
||||
def test_top_threat_throughput(self):
|
||||
from commander_ai_lab.sim.threat_assessor import top_threat
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
gs = _GS()
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
|
||||
start = time.perf_counter()
|
||||
for _ in range(self.ITERATIONS):
|
||||
top_threat(gs, viewer_seat=0)
|
||||
ta.top_threat(viewer_seat=0)
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
ops_per_sec = self.ITERATIONS / elapsed
|
||||
@@ -244,23 +236,20 @@ class BenchIntegrated:
|
||||
Simulate N full turns: threat assessment + politics check + memory record.
|
||||
Measures combined per-turn overhead of the AI subsystems.
|
||||
"""
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
from commander_ai_lab.sim.politics.memory import TargetingMemory, ActionType
|
||||
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
|
||||
from commander_ai_lab.sim.politics.engine import PoliticsEngine
|
||||
from commander_ai_lab.sim.politics.deals import DealType
|
||||
|
||||
gs = _GS()
|
||||
|
||||
def _threat_dict(viewer_seat: int) -> dict[int, float]:
|
||||
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
|
||||
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
mem = TargetingMemory(4)
|
||||
ch = PoliticsCommsChannel()
|
||||
pe = PoliticsEngine(
|
||||
4, mem, ch,
|
||||
{0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
|
||||
_threat_dict,
|
||||
lambda s: ta.assess_threats(viewer_seat=s),
|
||||
)
|
||||
|
||||
async def simulate():
|
||||
@@ -269,7 +258,7 @@ class BenchIntegrated:
|
||||
active = turn % 4
|
||||
# Assess threats for all seats
|
||||
for seat in range(4):
|
||||
assess_threats(gs, viewer_seat=seat)
|
||||
ta.assess_threats(viewer_seat=seat)
|
||||
# Record a simulated attack
|
||||
attacker = active
|
||||
defender = (active + 1) % 4
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
tests/test_check_ownership_dedup.py
|
||||
====================================
|
||||
Unit tests for duplicate-card deduplication in
|
||||
DeckGeneratorV3._check_ownership().
|
||||
|
||||
Uses an in-memory SQLite database so no real collection DB is required.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from coach.services.deck_generator import DeckGeneratorV3
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_conn() -> sqlite3.Connection:
|
||||
"""Return a seeded in-memory DB matching the collection_entries schema."""
|
||||
conn = sqlite3.connect(":memory:")
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("""
|
||||
CREATE TABLE collection_entries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
scryfall_id TEXT DEFAULT '',
|
||||
quantity INTEGER DEFAULT 0,
|
||||
tcg_price REAL DEFAULT 0,
|
||||
type_line TEXT DEFAULT '',
|
||||
cmc REAL DEFAULT 0
|
||||
)
|
||||
""")
|
||||
cards = [
|
||||
("Cultivate", "cult-001", 2, 1.50, "Sorcery", 3),
|
||||
("Sol Ring", "sol-001", 1, 5.00, "Artifact", 0),
|
||||
("Kodama's Reach", "kr-001", 1, 0.80, "Sorcery", 3),
|
||||
]
|
||||
conn.executemany(
|
||||
"INSERT INTO collection_entries (name, scryfall_id, quantity, tcg_price, type_line, cmc) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?)",
|
||||
cards,
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
def _make_generator(conn: sqlite3.Connection) -> DeckGeneratorV3:
|
||||
"""Build a DeckGeneratorV3 with a mock Perplexity client."""
|
||||
return DeckGeneratorV3(
|
||||
pplx_client=MagicMock(),
|
||||
db_conn_factory=lambda: conn,
|
||||
)
|
||||
|
||||
|
||||
# ── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCheckOwnershipDedup:
|
||||
|
||||
def setup_method(self):
|
||||
self.conn = _make_conn()
|
||||
self.gen = _make_generator(self.conn)
|
||||
|
||||
def test_duplicate_cards_merged(self):
|
||||
"""Duplicate card names should be merged into a single entry with summed count."""
|
||||
cards = [
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
]
|
||||
result = self.gen._check_ownership(cards)
|
||||
names = [c.name for c in result]
|
||||
|
||||
# No duplicate names (case-insensitive)
|
||||
assert len(names) == len(set(n.lower() for n in names)), (
|
||||
f"Duplicate names found in output: {names}"
|
||||
)
|
||||
# Only 2 unique cards
|
||||
assert len(result) == 2
|
||||
|
||||
# Merged count should be 2
|
||||
cultivate = next(c for c in result if c.name.lower() == "cultivate")
|
||||
assert cultivate.count == 2
|
||||
|
||||
def test_case_insensitive_dedup(self):
|
||||
"""Deduplication should be case-insensitive."""
|
||||
cards = [
|
||||
{"name": "cultivate", "count": 1, "category": "Ramp"},
|
||||
{"name": "CULTIVATE", "count": 1, "category": "Ramp"},
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
]
|
||||
result = self.gen._check_ownership(cards)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].count == 3
|
||||
|
||||
def test_warning_logged_on_duplicate(self, caplog):
|
||||
"""A WARNING log should be emitted when a duplicate is merged."""
|
||||
cards = [
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
]
|
||||
with caplog.at_level(logging.WARNING, logger="coach.deckgen"):
|
||||
self.gen._check_ownership(cards)
|
||||
|
||||
assert any("Duplicate" in msg or "merged" in msg for msg in caplog.messages), (
|
||||
f"Expected WARNING about duplicate/merged, got: {caplog.messages}"
|
||||
)
|
||||
|
||||
def test_no_duplicates_passes_through(self):
|
||||
"""When there are no duplicates, all cards pass through normally."""
|
||||
cards = [
|
||||
{"name": "Cultivate", "count": 1, "category": "Ramp"},
|
||||
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
|
||||
{"name": "Kodama's Reach", "count": 1, "category": "Ramp"},
|
||||
]
|
||||
result = self.gen._check_ownership(cards)
|
||||
|
||||
assert len(result) == 3
|
||||
assert all(c.count == 1 for c in result)
|
||||
|
||||
def test_empty_name_skipped(self):
|
||||
"""Cards with empty names should be silently skipped."""
|
||||
cards = [
|
||||
{"name": "", "count": 1},
|
||||
{"name": "Sol Ring", "count": 1, "category": "Ramp"},
|
||||
]
|
||||
result = self.gen._check_ownership(cards)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Sol Ring"
|
||||
@@ -1,160 +0,0 @@
|
||||
"""Tests for combat priority checkpoints (Issue #86 Item 1).
|
||||
|
||||
Validates CombatState lifecycle, split combat methods,
|
||||
priority windows, first-strike ordering, and instant-speed
|
||||
interaction during combat.
|
||||
"""
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import (
|
||||
Card, CombatState, Phase, Player, PlayerStats, SimState,
|
||||
)
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_creature(name, power, toughness, card_id, owner=0, keywords=None, is_commander=False):
|
||||
"""Build a minimal creature Card for testing."""
|
||||
return Card(
|
||||
name=name,
|
||||
type_line="Creature",
|
||||
power=str(power),
|
||||
toughness=str(toughness),
|
||||
pt=f"{power}/{toughness}",
|
||||
id=card_id,
|
||||
owner_id=owner,
|
||||
keywords=keywords or [],
|
||||
is_commander=is_commander,
|
||||
turn_played=-1,
|
||||
)
|
||||
|
||||
|
||||
def _two_player_sim(p0_creatures, p1_creatures, p0_life=40, p1_life=40):
|
||||
"""Build a minimal 2-player SimState with creatures on the battlefield."""
|
||||
sim = SimState(
|
||||
players=[
|
||||
Player(name="Attacker", life=p0_life, owner_id=0, stats=PlayerStats()),
|
||||
Player(name="Defender", life=p1_life, owner_id=1, stats=PlayerStats()),
|
||||
],
|
||||
battlefields=[list(p0_creatures), list(p1_creatures)],
|
||||
turn=2,
|
||||
)
|
||||
return sim
|
||||
|
||||
|
||||
# ── Tests ───────────────────────────────────────────────────────
|
||||
|
||||
class TestCombatState:
|
||||
"""CombatState dataclass lifecycle."""
|
||||
|
||||
def test_combat_state_defaults(self):
|
||||
cs = CombatState()
|
||||
assert cs.defending_seat == -1
|
||||
assert cs.attackers == {}
|
||||
assert cs.blockers == {}
|
||||
assert cs.player_damage == {}
|
||||
assert cs.first_strike_resolved is False
|
||||
assert cs.active is False
|
||||
|
||||
def test_combat_state_on_simstate(self):
|
||||
sim = SimState()
|
||||
assert sim.combat is None
|
||||
sim.combat = CombatState(active=True)
|
||||
assert sim.combat.active is True
|
||||
sim.combat = None
|
||||
assert sim.combat is None
|
||||
|
||||
|
||||
class TestAssignAttackers:
|
||||
"""Engine.assign_attackers() populates CombatState."""
|
||||
|
||||
def test_assigns_and_taps(self):
|
||||
atk = _make_creature("Bear", 3, 3, 100)
|
||||
sim = _two_player_sim([atk], [])
|
||||
engine = GameEngine()
|
||||
combat = engine.assign_attackers(sim, 0)
|
||||
assert combat is not None
|
||||
assert combat.active is True
|
||||
assert 100 in combat.attackers
|
||||
assert atk.tapped is True
|
||||
assert sim.combat is combat
|
||||
|
||||
def test_no_attack_returns_none(self):
|
||||
sim = _two_player_sim([], []) # no creatures
|
||||
engine = GameEngine()
|
||||
assert engine.assign_attackers(sim, 0) is None
|
||||
assert sim.combat is None
|
||||
|
||||
|
||||
class TestResolveCombatDamage:
|
||||
"""Engine.resolve_combat_damage() applies damage from CombatState."""
|
||||
|
||||
def test_unblocked_attacker_deals_damage(self):
|
||||
atk = _make_creature("Bear", 3, 3, 100)
|
||||
sim = _two_player_sim([atk], [], p1_life=20)
|
||||
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
|
||||
sim.combat = combat
|
||||
engine = GameEngine()
|
||||
engine.resolve_combat_damage(sim, 0, combat)
|
||||
assert sim.players[1].life == 17 # 20 - 3
|
||||
|
||||
def test_attacker_removed_before_damage_deals_nothing(self):
|
||||
"""Simulates instant-speed removal of attacker after declare attackers."""
|
||||
atk = _make_creature("Bear", 3, 3, 100)
|
||||
sim = _two_player_sim([atk], [], p1_life=20)
|
||||
combat = CombatState(defending_seat=1, attackers={100: 1}, active=True)
|
||||
sim.combat = combat
|
||||
# Simulate instant-speed removal: remove attacker before damage
|
||||
sim.remove_from_battlefield(100)
|
||||
engine = GameEngine()
|
||||
engine.resolve_combat_damage(sim, 0, combat)
|
||||
assert sim.players[1].life == 20 # no damage dealt
|
||||
|
||||
def test_first_strike_only_skips_non_fs(self):
|
||||
fs = _make_creature("Knight", 2, 2, 200, keywords=["first_strike"])
|
||||
normal = _make_creature("Bear", 3, 3, 201)
|
||||
sim = _two_player_sim([fs, normal], [], p1_life=20)
|
||||
combat = CombatState(
|
||||
defending_seat=1,
|
||||
attackers={200: 1, 201: 1},
|
||||
active=True,
|
||||
)
|
||||
sim.combat = combat
|
||||
engine = GameEngine()
|
||||
# First-strike step: only the knight deals damage
|
||||
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=True)
|
||||
assert sim.players[1].life == 18 # 20 - 2 (knight only)
|
||||
assert combat.first_strike_resolved is True
|
||||
# Normal damage step: bear deals damage, knight skipped
|
||||
engine.resolve_combat_damage(sim, 0, combat, first_strike_only=False)
|
||||
assert sim.players[1].life == 15 # 18 - 3 (bear only)
|
||||
|
||||
def test_combat_state_cleared_after_end_combat(self):
|
||||
"""Verifies sim.combat is None after combat cleanup."""
|
||||
sim = SimState()
|
||||
sim.combat = CombatState(active=True)
|
||||
# Simulate end-of-combat cleanup
|
||||
sim.combat = None
|
||||
assert sim.combat is None
|
||||
|
||||
def test_commander_damage_attributed(self):
|
||||
"""Commander attacker routes damage through commander_damage_by_card."""
|
||||
cmd = _make_creature("Atraxa", 4, 4, 300, is_commander=True)
|
||||
sim = _two_player_sim([cmd], [], p1_life=40)
|
||||
combat = CombatState(defending_seat=1, attackers={300: 1}, active=True)
|
||||
sim.combat = combat
|
||||
engine = GameEngine()
|
||||
engine.resolve_combat_damage(sim, 0, combat)
|
||||
assert sim.players[1].life == 36 # 40 - 4
|
||||
assert sim.players[1].commander_damage_by_card.get((0, "Atraxa"), 0) == 4
|
||||
|
||||
|
||||
class TestResponsePhasesCleanup:
|
||||
"""Verify BEGIN_COMBAT and END_COMBAT removed from RESPONSE_PHASES."""
|
||||
|
||||
def test_no_duplicate_apnap_begin_end_combat(self):
|
||||
from commander_ai_lab.sim.turn_manager import RESPONSE_PHASES
|
||||
assert "begin_combat" not in RESPONSE_PHASES
|
||||
assert "end_combat" not in RESPONSE_PHASES
|
||||
assert "upkeep" in RESPONSE_PHASES
|
||||
assert "end_step" in RESPONSE_PHASES
|
||||
@@ -1,410 +0,0 @@
|
||||
"""
|
||||
Commander Damage Regression Tests
|
||||
==================================
|
||||
Run with: pytest tests/test_commander_damage.py -v
|
||||
|
||||
Covers:
|
||||
- Commander source identity during combat
|
||||
- Trample overflow counts as commander damage
|
||||
- Double strike commander damage
|
||||
- Partner / multi-commander per-card tracking
|
||||
- 21-damage lethal check (per-card, not per-seat)
|
||||
- Commander damage through CommanderGameState helper
|
||||
- Non-commander creatures do NOT generate commander damage
|
||||
- Lifelink + commander damage interaction
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
from commander_ai_lab.sim.game_state import (
|
||||
CommanderGameState,
|
||||
CommanderPlayer,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _make_creature(
|
||||
name: str,
|
||||
power: str = "3",
|
||||
toughness: str = "3",
|
||||
keywords: list[str] | None = None,
|
||||
is_commander: bool = False,
|
||||
) -> Card:
|
||||
c = Card(
|
||||
name=name,
|
||||
type_line="Legendary Creature" if is_commander else "Creature",
|
||||
cmc=3,
|
||||
power=power,
|
||||
toughness=toughness,
|
||||
keywords=keywords or [],
|
||||
is_commander=is_commander,
|
||||
)
|
||||
return c
|
||||
|
||||
|
||||
def _make_land(name: str = "Mountain") -> Card:
|
||||
return Card(name=name, type_line="Basic Land", cmc=0)
|
||||
|
||||
|
||||
def _setup_combat(
|
||||
attacker_creatures: list[Card],
|
||||
blocker_creatures: list[Card] | None = None,
|
||||
attacker_life: int = 40,
|
||||
defender_life: int = 40,
|
||||
) -> tuple[GameEngine, SimState, int, int]:
|
||||
"""Set up a 2-player SimState ready for _resolve_combat.
|
||||
|
||||
Returns (engine, sim, attacker_seat, defender_seat).
|
||||
Attacker is seat 0, defender is seat 1.
|
||||
All creatures are pre-placed on battlefield, untapped, played on a previous turn.
|
||||
"""
|
||||
engine = GameEngine(max_turns=25, starting_life=40, record_log=True)
|
||||
sim = SimState(max_turns=25)
|
||||
sim.turn = 5 # so creatures played earlier pass summoning sickness check
|
||||
|
||||
p0 = Player(name="Attacker", life=attacker_life, owner_id=0, stats=PlayerStats())
|
||||
p1 = Player(name="Defender", life=defender_life, owner_id=1, stats=PlayerStats())
|
||||
sim.players = [p0, p1]
|
||||
sim.init_battlefields(2)
|
||||
|
||||
card_id = 90000
|
||||
for c in attacker_creatures:
|
||||
c.owner_id = 0
|
||||
c.tapped = False
|
||||
c.turn_played = 1 # played on an earlier turn
|
||||
c.id = card_id
|
||||
card_id += 1
|
||||
sim.add_to_battlefield(0, c)
|
||||
|
||||
for c in (blocker_creatures or []):
|
||||
c.owner_id = 1
|
||||
c.tapped = False
|
||||
c.turn_played = 1
|
||||
c.id = card_id
|
||||
card_id += 1
|
||||
sim.add_to_battlefield(1, c)
|
||||
|
||||
sim.next_card_id = card_id
|
||||
return engine, sim, 0, 1
|
||||
|
||||
|
||||
# ── Tests: Basic commander damage tracking ───────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageBasic:
|
||||
"""Commander creatures attacking unblocked should generate
|
||||
commander damage tracked on the defending player."""
|
||||
|
||||
def test_unblocked_commander_deals_commander_damage(self):
|
||||
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# Life reduced
|
||||
assert opp.life == 35
|
||||
# Per-seat aggregate
|
||||
assert opp.commander_damage_received[pi] == 5
|
||||
# Per-card breakdown
|
||||
assert opp.commander_damage_by_card[(pi, "Krenko")] == 5
|
||||
|
||||
def test_unblocked_non_commander_no_commander_damage(self):
|
||||
creature = _make_creature("Goblin", "4", "4", is_commander=False)
|
||||
engine, sim, pi, oi = _setup_combat([creature])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
assert opp.life == 36
|
||||
assert opp.commander_damage_received == {}
|
||||
assert opp.commander_damage_by_card == {}
|
||||
|
||||
def test_mixed_attackers_only_commander_tracked(self):
|
||||
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
|
||||
grunt = _make_creature("Goblin", "2", "2", is_commander=False)
|
||||
engine, sim, pi, oi = _setup_combat([cmd, grunt])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# Total damage = 3 + 2 = 5
|
||||
assert opp.life == 35
|
||||
# Only commander damage tracked
|
||||
assert opp.commander_damage_received[pi] == 3
|
||||
assert opp.commander_damage_by_card[(pi, "Krenko")] == 3
|
||||
|
||||
def test_cumulative_commander_damage_across_combats(self):
|
||||
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd])
|
||||
# First combat
|
||||
engine._resolve_combat(sim, pi, sim.turn, [])
|
||||
assert sim.players[oi].commander_damage_received[pi] == 5
|
||||
# Reset tapped state for second combat
|
||||
cmd.tapped = False
|
||||
sim.turn += 1
|
||||
engine._resolve_combat(sim, pi, sim.turn, [])
|
||||
assert sim.players[oi].commander_damage_received[pi] == 10
|
||||
assert sim.players[oi].commander_damage_by_card[(pi, "Krenko")] == 10
|
||||
|
||||
|
||||
# ── Tests: Commander damage lethal (21+) ─────────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageLethal:
|
||||
|
||||
def test_21_damage_eliminates(self):
|
||||
cmd = _make_creature("Krenko", "7", "7", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd], defender_life=100)
|
||||
# Deal 7 per combat, need 3 combats = 21
|
||||
for _ in range(3):
|
||||
cmd.tapped = False
|
||||
engine._resolve_combat(sim, pi, sim.turn, [])
|
||||
sim.turn += 1
|
||||
opp = sim.players[oi]
|
||||
assert opp.commander_damage_received[pi] == 21
|
||||
assert opp.eliminated
|
||||
|
||||
def test_20_damage_not_lethal(self):
|
||||
"""20 commander damage should NOT eliminate."""
|
||||
p = Player(name="Test", life=40)
|
||||
p.commander_damage_received = {0: 20}
|
||||
p.commander_damage_by_card = {(0, "Krenko"): 20}
|
||||
assert not p.is_dead_to_commander_damage()
|
||||
|
||||
def test_21_per_card_not_per_seat(self):
|
||||
"""With partners, 21 must come from a SINGLE commander, not combined."""
|
||||
p = Player(name="Test", life=40)
|
||||
# Two partner commanders from seat 0, 11 each = 22 total but neither is 21
|
||||
p.commander_damage_by_card = {
|
||||
(0, "Brallin"): 11,
|
||||
(0, "Shabraz"): 11,
|
||||
}
|
||||
p.commander_damage_received = {0: 22}
|
||||
assert not p.is_dead_to_commander_damage()
|
||||
|
||||
def test_21_from_one_partner_is_lethal(self):
|
||||
"""If one partner reaches 21, that's lethal even if the other has 0."""
|
||||
p = Player(name="Test", life=40)
|
||||
p.commander_damage_by_card = {
|
||||
(0, "Brallin"): 21,
|
||||
(0, "Shabraz"): 3,
|
||||
}
|
||||
p.commander_damage_received = {0: 24}
|
||||
assert p.is_dead_to_commander_damage()
|
||||
|
||||
def test_backward_compat_no_by_card(self):
|
||||
"""If commander_damage_by_card is empty, fall back to per-seat check."""
|
||||
p = Player(name="Test", life=40)
|
||||
p.commander_damage_received = {0: 21}
|
||||
# No by_card data (older game state)
|
||||
assert p.is_dead_to_commander_damage()
|
||||
|
||||
|
||||
# ── Tests: Trample commander damage ──────────────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageTrample:
|
||||
|
||||
def test_trample_overflow_is_commander_damage(self):
|
||||
"""When a commander with trample is chump-blocked, overflow to
|
||||
the player should count as commander damage."""
|
||||
cmd = _make_creature("Ghalta", "12", "12", keywords=["trample"], is_commander=True)
|
||||
# Defender life low enough that AI decides to chump-block
|
||||
# (opp.life <= a_pow * 2 ⇒ 20 <= 24)
|
||||
blocker = _make_creature("Bear", "2", "2")
|
||||
engine, sim, pi, oi = _setup_combat([cmd], [blocker], defender_life=20)
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# Bear (2/2) chump-blocks Ghalta (12/12 trample).
|
||||
# Trample overflow = 12 - 2 = 10 damage to player.
|
||||
assert opp.commander_damage_received[pi] == 10
|
||||
assert opp.commander_damage_by_card[(pi, "Ghalta")] == 10
|
||||
assert opp.life == 10
|
||||
|
||||
def test_fully_blocked_commander_no_player_damage(self):
|
||||
"""Commander fully absorbed by a survivable blocker — no commander
|
||||
damage to the defending player."""
|
||||
cmd = _make_creature("Krenko", "3", "3", is_commander=True)
|
||||
# Blocker toughness > attacker power so AI selects it
|
||||
blocker = _make_creature("Wall", "0", "5")
|
||||
engine, sim, pi, oi = _setup_combat([cmd], [blocker])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# No damage to player (absorbed by blocker, no trample)
|
||||
assert opp.life == 40
|
||||
assert opp.commander_damage_received == {}
|
||||
|
||||
|
||||
# ── Tests: Double strike commander damage ────────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageDoubleStrike:
|
||||
|
||||
def test_unblocked_double_strike_commander(self):
|
||||
cmd = _make_creature("Rafiq", "5", "5", keywords=["double strike"], is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# Double strike unblocked = power * 2
|
||||
assert opp.life == 30
|
||||
assert opp.commander_damage_received[pi] == 10
|
||||
assert opp.commander_damage_by_card[(pi, "Rafiq")] == 10
|
||||
|
||||
|
||||
# ── Tests: Partner / multi-commander ─────────────────────────
|
||||
|
||||
|
||||
class TestPartnerCommanderDamage:
|
||||
|
||||
def test_two_partner_commanders_tracked_separately(self):
|
||||
cmd_a = _make_creature("Brallin", "4", "4", is_commander=True)
|
||||
cmd_b = _make_creature("Shabraz", "3", "3", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
opp = sim.players[oi]
|
||||
# Total damage = 4 + 3 = 7
|
||||
assert opp.life == 33
|
||||
# Aggregate per-seat
|
||||
assert opp.commander_damage_received[pi] == 7
|
||||
# Per-card individual
|
||||
assert opp.commander_damage_by_card[(pi, "Brallin")] == 4
|
||||
assert opp.commander_damage_by_card[(pi, "Shabraz")] == 3
|
||||
|
||||
def test_partner_21_check_per_card(self):
|
||||
"""Even with partners, only 21 from ONE commander kills."""
|
||||
cmd_a = _make_creature("Brallin", "7", "7", is_commander=True)
|
||||
cmd_b = _make_creature("Shabraz", "7", "7", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd_a, cmd_b], defender_life=100)
|
||||
# 3 combats: 7*3=21 for Brallin, 7*3=21 for Shabraz
|
||||
# After 2 combats: 14 each — not lethal
|
||||
for _ in range(2):
|
||||
cmd_a.tapped = False
|
||||
cmd_b.tapped = False
|
||||
engine._resolve_combat(sim, pi, sim.turn, [])
|
||||
sim.turn += 1
|
||||
opp = sim.players[oi]
|
||||
assert not opp.eliminated # 14 from each, neither is 21
|
||||
# 3rd combat pushes both to 21
|
||||
cmd_a.tapped = False
|
||||
cmd_b.tapped = False
|
||||
engine._resolve_combat(sim, pi, sim.turn, [])
|
||||
assert opp.eliminated
|
||||
|
||||
|
||||
# ── Tests: CommanderGameState integration ────────────────────
|
||||
|
||||
|
||||
class TestCommanderGameStateDamage:
|
||||
|
||||
def test_deal_commander_damage_with_name(self):
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
for i in range(2):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
|
||||
gs = CommanderGameState.from_sim_state(sim)
|
||||
gs.deal_commander_damage(0, 1, 10, commander_name="Krenko")
|
||||
cp = gs.commander_players[1]
|
||||
assert cp.commander_damage_received[0] == 10
|
||||
assert cp.base.commander_damage_by_card[(0, "Krenko")] == 10
|
||||
assert cp.life == 30
|
||||
|
||||
def test_deal_commander_damage_without_name_backward_compat(self):
|
||||
sim = SimState()
|
||||
sim.init_battlefields(2)
|
||||
for i in range(2):
|
||||
sim.players.append(Player(name=f"P{i}", life=40, owner_id=i))
|
||||
gs = CommanderGameState.from_sim_state(sim)
|
||||
gs.deal_commander_damage(0, 1, 10)
|
||||
cp = gs.commander_players[1]
|
||||
assert cp.commander_damage_received[0] == 10
|
||||
assert cp.base.commander_damage_by_card == {}
|
||||
assert cp.life == 30
|
||||
|
||||
def test_commander_player_property_delegates_to_base(self):
|
||||
"""CommanderPlayer.commander_damage_received should be the same
|
||||
object as base.commander_damage_received."""
|
||||
p = Player(name="Test", life=40)
|
||||
cp = CommanderPlayer(base=p)
|
||||
# Write through base
|
||||
p.commander_damage_received[0] = 15
|
||||
# Read through wrapper
|
||||
assert cp.commander_damage_received[0] == 15
|
||||
# Write through wrapper
|
||||
cp.commander_damage_received[1] = 7
|
||||
assert p.commander_damage_received[1] == 7
|
||||
|
||||
|
||||
# ── Tests: Event log annotation ──────────────────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageEvents:
|
||||
|
||||
def test_event_log_includes_cmdr_annotation(self):
|
||||
cmd = _make_creature("Krenko", "5", "5", is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
# Should mention commander damage in the event text
|
||||
damage_event = [e for e in events if "combat damage" in e]
|
||||
assert len(damage_event) == 1
|
||||
assert "5 cmdr" in damage_event[0]
|
||||
|
||||
def test_no_cmdr_annotation_for_non_commanders(self):
|
||||
grunt = _make_creature("Goblin", "3", "3", is_commander=False)
|
||||
engine, sim, pi, oi = _setup_combat([grunt])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
damage_event = [e for e in events if "combat damage" in e]
|
||||
assert len(damage_event) == 1
|
||||
assert "cmdr" not in damage_event[0]
|
||||
|
||||
|
||||
# ── Tests: Lifelink + commander interaction ──────────────────
|
||||
|
||||
|
||||
class TestCommanderDamageLifelink:
|
||||
|
||||
def test_commander_with_lifelink_heals_and_tracks(self):
|
||||
cmd = _make_creature("Oloro", "5", "5", keywords=["lifelink"], is_commander=True)
|
||||
engine, sim, pi, oi = _setup_combat([cmd])
|
||||
events: list[str] = []
|
||||
engine._resolve_combat(sim, pi, sim.turn, events)
|
||||
attacker = sim.players[pi]
|
||||
opp = sim.players[oi]
|
||||
# Commander damage tracked
|
||||
assert opp.commander_damage_received[pi] == 5
|
||||
# Lifelink healed attacker
|
||||
assert attacker.life == 45
|
||||
# Defender took damage
|
||||
assert opp.life == 35
|
||||
|
||||
|
||||
# ── Tests: Player model ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlayerCommanderDamageModel:
|
||||
|
||||
def test_fresh_player_has_empty_tracking(self):
|
||||
p = Player(name="Test")
|
||||
assert p.commander_damage_received == {}
|
||||
assert p.commander_damage_by_card == {}
|
||||
assert not p.is_dead_to_commander_damage()
|
||||
|
||||
def test_is_dead_prefers_by_card(self):
|
||||
"""When by_card data exists, per-seat aggregate is ignored for lethal check."""
|
||||
p = Player(name="Test")
|
||||
p.commander_damage_received = {0: 25} # aggregate says lethal
|
||||
p.commander_damage_by_card = {
|
||||
(0, "CmdA"): 12,
|
||||
(0, "CmdB"): 13,
|
||||
}
|
||||
# Per-card: neither commander has 21 → NOT lethal
|
||||
assert not p.is_dead_to_commander_damage()
|
||||
+16
-52
@@ -25,7 +25,7 @@ import pytest
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -42,38 +42,15 @@ class _Player:
|
||||
library: list = field(default_factory=list)
|
||||
|
||||
|
||||
class _StubSimState:
|
||||
"""Minimal SimState stub — delegates to player battlefields."""
|
||||
|
||||
def __init__(self, players: list) -> None:
|
||||
self._players = players
|
||||
|
||||
def get_battlefield(self, seat: int) -> list:
|
||||
if 0 <= seat < len(self._players):
|
||||
return self._players[seat].battlefield
|
||||
return []
|
||||
|
||||
|
||||
class _GS:
|
||||
def __init__(self, n=4):
|
||||
self.players = [_Player(f"P{i}", i) for i in range(n)]
|
||||
self.turn = 1
|
||||
self.current_phase = "main1"
|
||||
self.active_player_seat = 0
|
||||
self.priority_seat = 0
|
||||
self.stack = []
|
||||
self.game_over = False
|
||||
self.winner = None
|
||||
self.sim_state = _StubSimState(self.players)
|
||||
|
||||
def battlefield(self, seat: int) -> list:
|
||||
return self.players[seat].battlefield
|
||||
|
||||
def stack_is_empty(self) -> bool:
|
||||
return len(self.stack) == 0
|
||||
|
||||
def living_players(self) -> list:
|
||||
return [p for p in self.players if not p.eliminated]
|
||||
|
||||
def get_legal_moves(self, seat):
|
||||
return [{"id": 1, "category": "pass_priority", "description": "Pass"}]
|
||||
@@ -93,17 +70,13 @@ class TestThreatPoliticsIntegration:
|
||||
"""
|
||||
|
||||
def _build(self):
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
from commander_ai_lab.sim.threat_assessor import ThreatAssessor
|
||||
from commander_ai_lab.sim.politics.memory import TargetingMemory
|
||||
from commander_ai_lab.sim.politics.comms import PoliticsCommsChannel
|
||||
from commander_ai_lab.sim.politics.engine import PoliticsEngine
|
||||
|
||||
gs = _GS()
|
||||
|
||||
def _threat_dict(viewer_seat: int) -> dict[int, float]:
|
||||
"""Wrap assess_threats list into {seat: total} dict for PoliticsEngine."""
|
||||
return {s.seat: s.total for s in assess_threats(gs, viewer_seat=viewer_seat)}
|
||||
|
||||
ta = ThreatAssessor(game_state=gs, num_players=4)
|
||||
mem = TargetingMemory(num_players=4)
|
||||
ch = PoliticsCommsChannel()
|
||||
pe = PoliticsEngine(
|
||||
@@ -111,26 +84,19 @@ class TestThreatPoliticsIntegration:
|
||||
memory=mem,
|
||||
comms=ch,
|
||||
personalities={0:"timmy",1:"spike",2:"johnny",3:"aggressive"},
|
||||
threat_fn=_threat_dict,
|
||||
threat_fn=lambda s: ta.assess_threats(viewer_seat=s),
|
||||
)
|
||||
return gs, mem, ch, pe
|
||||
return gs, ta, mem, ch, pe
|
||||
|
||||
def test_adjusted_threat_no_spite_equals_base(self):
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
gs, mem, ch, pe = self._build()
|
||||
scores = assess_threats(gs, viewer_seat=0)
|
||||
# Find the score for seat 1
|
||||
base = 0.0
|
||||
for s in scores:
|
||||
if s.seat == 1:
|
||||
base = s.total
|
||||
break
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
base = ta.assess_threats(viewer_seat=0).get(1, 0.0)
|
||||
adjusted = pe.adjusted_threat_score(viewer=0, target=1, current_turn=1)
|
||||
# Without spite or memory events, adjusted ≈ base (may differ by mem term)
|
||||
assert abs(adjusted - base) < 0.35
|
||||
|
||||
def test_spite_raises_adjusted_score(self):
|
||||
gs, mem, ch, pe = self._build()
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
before = pe.adjusted_threat_score(0, 1, 1)
|
||||
pe._add_spite(wronged_seat=0, target_seat=1, current_turn=1, intensity=0.5)
|
||||
after = pe.adjusted_threat_score(0, 1, 1)
|
||||
@@ -138,7 +104,7 @@ class TestThreatPoliticsIntegration:
|
||||
|
||||
def test_memory_aggression_raises_adjusted_score(self):
|
||||
from commander_ai_lab.sim.politics.memory import ActionType
|
||||
gs, mem, ch, pe = self._build()
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
before = pe.adjusted_threat_score(0, 2, 1)
|
||||
for _ in range(5):
|
||||
mem.record(1, 2, 0, ActionType.ATTACKED)
|
||||
@@ -146,14 +112,12 @@ class TestThreatPoliticsIntegration:
|
||||
assert after > before
|
||||
|
||||
def test_top_threat_matches_assessor(self):
|
||||
from commander_ai_lab.sim.threat_assessor import assess_threats
|
||||
gs, mem, ch, pe = self._build()
|
||||
gs, ta, mem, ch, pe = self._build()
|
||||
# Give seat 3 a huge life lead to inflate threat
|
||||
gs.players[3].life = 5 # low life — looks like they need a kill
|
||||
threats = assess_threats(gs, viewer_seat=0)
|
||||
# assess_threats returns a ThreatScore per non-eliminated seat excluding none
|
||||
# With 4 players, viewer seat 0 is included → 4 scores
|
||||
assert len(threats) == 4
|
||||
threats = ta.assess_threats(viewer_seat=0)
|
||||
# Just confirm assess_threats returns 3 keys for a 4-player game
|
||||
assert len(threats) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -315,7 +279,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=2),
|
||||
)
|
||||
asyncio.run(tm.run_game())
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
# Game should have fired at least one event
|
||||
assert len(events) >= 1
|
||||
|
||||
@@ -343,7 +307,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=1),
|
||||
)
|
||||
asyncio.run(tm.run_game())
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
# If phases were emitted, they should start with untap
|
||||
if phases_seen:
|
||||
assert phases_seen[0] in ("untap", "upkeep", "draw", "main1")
|
||||
@@ -373,7 +337,7 @@ class TestTurnManagerIntegration:
|
||||
on_thinking=on_thinking,
|
||||
config=TurnManagerConfig(ai_decision_delay=0.0, max_turns=3),
|
||||
)
|
||||
asyncio.run(tm.run_game())
|
||||
asyncio.get_event_loop().run_until_complete(tm.run_game())
|
||||
assert game_over_fired[0] is True
|
||||
|
||||
|
||||
|
||||
@@ -92,9 +92,9 @@ class TestCommanderPlayer:
|
||||
|
||||
def test_commander_damage_death(self):
|
||||
cp = CommanderPlayer(base=Player(name="Test"))
|
||||
cp.base.commander_damage_received = {0: 20, 1: 5}
|
||||
cp.commander_damage_received = {0: 20, 1: 5}
|
||||
assert not cp.is_dead_to_commander_damage()
|
||||
cp.base.commander_damage_received[0] = 21
|
||||
cp.commander_damage_received[0] = 21
|
||||
assert cp.is_dead_to_commander_damage()
|
||||
|
||||
def test_to_dict_json_serializable(self):
|
||||
|
||||
+4
-65
@@ -39,34 +39,6 @@ class _StubPlayer:
|
||||
library: list = field(default_factory=list)
|
||||
graveyard: list = field(default_factory=list)
|
||||
battlefield: list = field(default_factory=list)
|
||||
exile: list = field(default_factory=list)
|
||||
command_zone: list = field(default_factory=list)
|
||||
commander_zone: list = field(default_factory=list)
|
||||
commander_cast_count: int = 0
|
||||
commander_damage_received: dict = field(default_factory=dict)
|
||||
|
||||
def commander_tax(self) -> int:
|
||||
return self.commander_cast_count * 2
|
||||
|
||||
class mana_pool:
|
||||
"""Stub mana pool."""
|
||||
W = U = B = R = G = C = 0
|
||||
def total(self) -> int: return 0
|
||||
def to_dict(self) -> dict: return {"W":0,"U":0,"B":0,"R":0,"G":0,"C":0,"total":0}
|
||||
|
||||
mana_pool = mana_pool()
|
||||
|
||||
|
||||
class _StubSimState:
|
||||
"""Minimal SimState stub — delegates to player battlefields."""
|
||||
|
||||
def __init__(self, players: list) -> None:
|
||||
self._players = players
|
||||
|
||||
def get_battlefield(self, seat: int) -> list:
|
||||
if 0 <= seat < len(self._players):
|
||||
return self._players[seat].battlefield
|
||||
return []
|
||||
|
||||
|
||||
class _StubGameState:
|
||||
@@ -74,35 +46,11 @@ class _StubGameState:
|
||||
|
||||
def __init__(self, num_players: int = 4):
|
||||
self.players = [_StubPlayer(name=f"P{i}", life=40) for i in range(num_players)]
|
||||
self.commander_players = self.players # prompt_builder iterates this
|
||||
self.turn = 0
|
||||
self.current_phase = "main1"
|
||||
self.active_player_seat = 0
|
||||
self.priority_seat = 0
|
||||
self.land_drop_used = False
|
||||
self._move_counter = 0
|
||||
self.stack: list = []
|
||||
self.sim_state = _StubSimState(self.players)
|
||||
|
||||
def active_player(self):
|
||||
if 0 <= self.active_player_seat < len(self.players):
|
||||
return self.players[self.active_player_seat]
|
||||
return None
|
||||
|
||||
def priority_player(self):
|
||||
if 0 <= self.priority_seat < len(self.players):
|
||||
return self.players[self.priority_seat]
|
||||
return None
|
||||
|
||||
def battlefield(self, seat: int) -> list:
|
||||
"""Return the battlefield for the given seat."""
|
||||
return self.players[seat].battlefield
|
||||
|
||||
def stack_is_empty(self) -> bool:
|
||||
return len(self.stack) == 0
|
||||
|
||||
def living_players(self) -> list:
|
||||
return [p for p in self.players if not p.eliminated]
|
||||
|
||||
def get_legal_moves(self, seat: int) -> list[dict]:
|
||||
"""Always return one pass move per seat."""
|
||||
@@ -114,14 +62,6 @@ class _StubGameState:
|
||||
def apply_move(self, seat: int, move_id: int) -> None:
|
||||
self._move_counter += 1
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"turn": self.turn,
|
||||
"current_phase": self.current_phase,
|
||||
"active_player_seat": self.active_player_seat,
|
||||
"players": [{"name": p.name, "life": p.life} for p in self.players],
|
||||
}
|
||||
|
||||
|
||||
def _stub_gs(num_players: int = 4) -> _StubGameState:
|
||||
return _StubGameState(num_players=num_players)
|
||||
@@ -160,13 +100,12 @@ def _make_tm(
|
||||
class TestThreatAssessor:
|
||||
def _gs_with_creatures(self) -> _StubGameState:
|
||||
gs = _stub_gs(4)
|
||||
# Give seat 0 a big creature — use Card from sim.models for full API compat
|
||||
from commander_ai_lab.sim.models import Card
|
||||
# Give seat 0 a big creature
|
||||
gs.players[0].battlefield = [
|
||||
Card(name="Dragon", type_line="Creature — Dragon", pt="6/6", power="6", toughness="6"),
|
||||
Card(name="Forest", type_line="Basic Land — Forest"),
|
||||
{"name": "Dragon", "type": "creature", "pt": "6/6", "oracle": ""},
|
||||
{"name": "Forest", "type": "land", "pt": "", "oracle": ""},
|
||||
]
|
||||
gs.players[1].hand = [Card(name=f"Card{i}") for i in range(7)]
|
||||
gs.players[1].hand = [{"name": f"Card{i}"} for i in range(7)]
|
||||
return gs
|
||||
|
||||
def test_returns_score_per_non_eliminated_player(self):
|
||||
|
||||
+3
-12
@@ -15,21 +15,12 @@ import pytest
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_session_store():
|
||||
"""Reset SessionStore singleton and api module _store before/after each test."""
|
||||
from commander_ai_lab.web.session_store import SessionStore
|
||||
SessionStore._instance = None
|
||||
# Also reset the module-level _store in the API router
|
||||
import commander_ai_lab.web.routers.api as api_mod
|
||||
api_mod._store = SessionStore()
|
||||
yield
|
||||
SessionStore._instance = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create a fresh FastAPI app for each test."""
|
||||
# Reset singleton so tests don't share session state
|
||||
from commander_ai_lab.web.session_store import SessionStore
|
||||
SessionStore._instance = None
|
||||
from commander_ai_lab.web.app import create_app
|
||||
return create_app()
|
||||
|
||||
|
||||
@@ -1,291 +0,0 @@
|
||||
"""
|
||||
Phase 5 Architecture Follow-Up — Unit Tests
|
||||
============================================
|
||||
Tests for the three approved decisions:
|
||||
1. Zone embedding boundary is explicit (docs + code + assertion)
|
||||
2. SQLite WAL-backed online learning store
|
||||
3. Forge-only default weights + mixed-mode presets
|
||||
|
||||
Run with: pytest tests/test_phase5_followup.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Decision 1: Zone Embedding Boundary
|
||||
# ====================================================================
|
||||
|
||||
class TestZoneEmbeddingBoundary:
|
||||
"""Verify that the zone embedding contract is explicit and enforced."""
|
||||
|
||||
def test_state_dims_total_zone_dim(self):
|
||||
from ml.config.scope import STATE_DIMS
|
||||
# 4 zones × 2 players × 768 = 6144
|
||||
assert STATE_DIMS.total_zone_dim == 4 * 2 * 768 == 6144
|
||||
|
||||
def test_state_dims_total_state_dim(self):
|
||||
from ml.config.scope import STATE_DIMS
|
||||
# 29 global + 6144 zone + 4 playstyle = 6177
|
||||
assert STATE_DIMS.total_state_dim == 6177
|
||||
|
||||
def test_zone_pool_dim_matches_card_embedding_dim(self):
|
||||
from ml.config.scope import STATE_DIMS
|
||||
# Zone pool output is same size as card embedding input
|
||||
assert STATE_DIMS.zone_pool_dim == STATE_DIMS.card_embedding_dim == 768
|
||||
|
||||
def test_empty_zone_produces_zero_vector(self):
|
||||
"""Empty zone must produce a zero vector of exactly zone_pool_dim."""
|
||||
from ml.config.scope import STATE_DIMS
|
||||
from ml.encoder.state_encoder import CardEmbeddingIndex
|
||||
|
||||
idx = CardEmbeddingIndex()
|
||||
# Even without loading, mean_pool_zone returns a zero vec
|
||||
vec = idx.mean_pool_zone([])
|
||||
assert vec.shape == (STATE_DIMS.zone_pool_dim,)
|
||||
assert np.all(vec == 0.0)
|
||||
|
||||
def test_encode_zones_shape_with_empty_players(self):
|
||||
"""_encode_zones must always return exactly total_zone_dim floats."""
|
||||
from ml.config.scope import STATE_DIMS
|
||||
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
||||
|
||||
idx = CardEmbeddingIndex()
|
||||
enc = StateEncoder(idx)
|
||||
|
||||
# Two players with empty zones
|
||||
players = [
|
||||
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
|
||||
{"hand": [], "battlefield": [], "graveyard": [], "command_zone": []},
|
||||
]
|
||||
zone_vec = enc._encode_zones(players)
|
||||
assert zone_vec.shape == (STATE_DIMS.total_zone_dim,)
|
||||
# All zeros because all zones are empty
|
||||
assert np.all(zone_vec == 0.0)
|
||||
|
||||
def test_full_encode_shape(self):
|
||||
"""Full state vector must be exactly total_state_dim."""
|
||||
from ml.config.scope import STATE_DIMS
|
||||
from ml.encoder.state_encoder import CardEmbeddingIndex, StateEncoder
|
||||
|
||||
idx = CardEmbeddingIndex()
|
||||
enc = StateEncoder(idx)
|
||||
|
||||
decision = {
|
||||
"turn": 3,
|
||||
"phase": "main_1",
|
||||
"active_seat": 0,
|
||||
"players": [
|
||||
{
|
||||
"seat": 0, "life": 40, "cmdr_dmg": 0, "mana": 4,
|
||||
"cmdr_tax": 0, "creatures": 2, "lands": 4,
|
||||
"hand": ["Island", "Counterspell"],
|
||||
"battlefield": ["Tundra"],
|
||||
"graveyard": [],
|
||||
"command_zone": [],
|
||||
},
|
||||
{
|
||||
"seat": 1, "life": 38, "cmdr_dmg": 0, "mana": 3,
|
||||
"cmdr_tax": 0, "creatures": 1, "lands": 3,
|
||||
"hand": [],
|
||||
"battlefield": [],
|
||||
"graveyard": [],
|
||||
"command_zone": [],
|
||||
},
|
||||
],
|
||||
}
|
||||
state = enc.encode(decision, playstyle="control")
|
||||
assert state.shape == (STATE_DIMS.total_state_dim,)
|
||||
assert state.dtype == np.float32
|
||||
|
||||
def test_boundary_docstring_present(self):
|
||||
"""StateDimensions must document the zone embedding boundary."""
|
||||
from ml.config.scope import StateDimensions
|
||||
assert "zone embedding boundary" in StateDimensions.__doc__.lower()
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Decision 2: SQLite WAL Online Learning Store
|
||||
# ====================================================================
|
||||
|
||||
class TestOnlineLearningStore:
|
||||
"""Verify the SQLite WAL-backed online learning store."""
|
||||
|
||||
@pytest.fixture
|
||||
def store(self, tmp_path):
|
||||
from ml.serving.online_learning_store import OnlineLearningStore
|
||||
db_path = str(tmp_path / "test_online.db")
|
||||
s = OnlineLearningStore(db_path=db_path)
|
||||
s.init_db()
|
||||
return s
|
||||
|
||||
def test_init_creates_db(self, store):
|
||||
assert os.path.exists(store.db_path)
|
||||
|
||||
def test_wal_mode_enabled(self, store):
|
||||
conn = store._get_conn()
|
||||
mode = conn.execute("PRAGMA journal_mode").fetchone()[0]
|
||||
assert mode == "wal"
|
||||
|
||||
def test_record_and_fetch(self, store):
|
||||
snapshot = {"turn": 1, "phase": "main_1", "players": []}
|
||||
row_id = store.record_decision(
|
||||
snapshot=snapshot,
|
||||
action_idx=3,
|
||||
confidence=0.85,
|
||||
playstyle="control",
|
||||
temperature=0.8,
|
||||
greedy=False,
|
||||
)
|
||||
assert row_id >= 1
|
||||
|
||||
decisions = store.fetch_decisions()
|
||||
assert len(decisions) == 1
|
||||
d = decisions[0]
|
||||
assert d["action_idx"] == 3
|
||||
assert d["confidence"] == pytest.approx(0.85)
|
||||
assert d["playstyle"] == "control"
|
||||
assert d["temperature"] == pytest.approx(0.8)
|
||||
assert d["greedy"] == 0
|
||||
assert d["snapshot"]["turn"] == 1
|
||||
|
||||
def test_count(self, store):
|
||||
assert store.count() == 0
|
||||
store.record_decision({"t": 1}, 0, 0.5)
|
||||
store.record_decision({"t": 2}, 1, 0.6)
|
||||
assert store.count() == 2
|
||||
assert store.count(only_unexported=True) == 2
|
||||
|
||||
def test_mark_exported(self, store):
|
||||
store.record_decision({"t": 1}, 0, 0.5)
|
||||
store.record_decision({"t": 2}, 1, 0.6)
|
||||
store.record_decision({"t": 3}, 2, 0.7)
|
||||
|
||||
# Mark first two as exported
|
||||
updated = store.mark_exported(up_to_rowid=2)
|
||||
assert updated == 2
|
||||
assert store.count(only_unexported=True) == 1
|
||||
|
||||
# Fetch only unexported
|
||||
remaining = store.fetch_decisions(only_unexported=True)
|
||||
assert len(remaining) == 1
|
||||
assert remaining[0]["action_idx"] == 2
|
||||
|
||||
def test_fetch_since_rowid(self, store):
|
||||
ids = []
|
||||
for i in range(5):
|
||||
ids.append(store.record_decision({"t": i}, i, 0.5))
|
||||
|
||||
# Fetch only rows after id 3
|
||||
results = store.fetch_decisions(since_rowid=ids[2], only_unexported=False)
|
||||
assert len(results) == 2 # ids[3] and ids[4]
|
||||
assert results[0]["action_idx"] == 3
|
||||
|
||||
def test_fetch_limit(self, store):
|
||||
for i in range(10):
|
||||
store.record_decision({"t": i}, i % 8, 0.5)
|
||||
results = store.fetch_decisions(limit=3)
|
||||
assert len(results) == 3
|
||||
|
||||
def test_close_and_reopen(self, store):
|
||||
store.record_decision({"t": 1}, 0, 0.5)
|
||||
store.close()
|
||||
# Re-open by calling _get_conn again
|
||||
assert store.count() == 1
|
||||
|
||||
def test_snapshot_stored_as_compact_json(self, store):
|
||||
"""Snapshots should be stored as compact JSON (no extra whitespace)."""
|
||||
store.record_decision({"key": "value", "num": 42}, 0, 0.5)
|
||||
conn = store._get_conn()
|
||||
raw = conn.execute(
|
||||
"SELECT snapshot FROM online_decisions WHERE id=1"
|
||||
).fetchone()[0]
|
||||
# Compact JSON has no spaces after separators
|
||||
assert " " not in raw
|
||||
assert json.loads(raw) == {"key": "value", "num": 42}
|
||||
|
||||
|
||||
# ====================================================================
|
||||
# Decision 3: Forge-Only Defaults + Mixed-Mode Presets
|
||||
# ====================================================================
|
||||
|
||||
class TestForgeOnlyDefaults:
|
||||
"""Verify default weights are Forge-only (ppo_weight=0.0)."""
|
||||
|
||||
def test_distillation_defaults_forge_only(self):
|
||||
from ml.config.scope import DISTILLATION_DEFAULTS
|
||||
assert DISTILLATION_DEFAULTS.forge_weight == 1.0
|
||||
assert DISTILLATION_DEFAULTS.ppo_weight == 0.0
|
||||
|
||||
def test_distillation_config_forge_only(self):
|
||||
from ml.training.distillation_loop import DistillationConfig
|
||||
cfg = DistillationConfig()
|
||||
assert cfg.forge_weight == 1.0
|
||||
assert cfg.ppo_weight == 0.0
|
||||
|
||||
def test_dataset_config_forge_only(self):
|
||||
from ml.data.dataset_builder import DatasetConfig
|
||||
cfg = DatasetConfig()
|
||||
assert cfg.source_weights["forge"] == 1.0
|
||||
assert cfg.source_weights["ppo"] == 0.0
|
||||
|
||||
|
||||
class TestMixedModePresets:
|
||||
"""Verify mixed-mode weight presets exist and are correct."""
|
||||
|
||||
def test_presets_dict_exists(self):
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
assert isinstance(MIXED_MODE_PRESETS, dict)
|
||||
assert len(MIXED_MODE_PRESETS) == 3
|
||||
|
||||
def test_forge_only_preset(self):
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
p = MIXED_MODE_PRESETS["forge_only"]
|
||||
assert p["forge_weight"] == 1.0
|
||||
assert p["ppo_weight"] == 0.0
|
||||
|
||||
def test_forge_90_10_preset(self):
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
p = MIXED_MODE_PRESETS["forge_90_10"]
|
||||
assert p["forge_weight"] == 1.0
|
||||
# ppo_weight ~0.11 gives approximately 10% PPO in the mix
|
||||
assert 0.1 <= p["ppo_weight"] <= 0.15
|
||||
|
||||
def test_forge_80_20_preset(self):
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
p = MIXED_MODE_PRESETS["forge_80_20"]
|
||||
assert p["forge_weight"] == 1.0
|
||||
# ppo_weight ~0.25 gives approximately 20% PPO
|
||||
assert 0.2 <= p["ppo_weight"] <= 0.3
|
||||
|
||||
def test_get_preset_weights_valid(self):
|
||||
from ml.config.scope import get_preset_weights
|
||||
w = get_preset_weights("forge_90_10")
|
||||
assert "forge_weight" in w
|
||||
assert "ppo_weight" in w
|
||||
|
||||
def test_get_preset_weights_returns_copy(self):
|
||||
"""get_preset_weights must return a copy, not the original dict."""
|
||||
from ml.config.scope import get_preset_weights, MIXED_MODE_PRESETS
|
||||
w = get_preset_weights("forge_only")
|
||||
w["forge_weight"] = 999
|
||||
assert MIXED_MODE_PRESETS["forge_only"]["forge_weight"] == 1.0
|
||||
|
||||
def test_get_preset_weights_invalid(self):
|
||||
from ml.config.scope import get_preset_weights
|
||||
with pytest.raises(KeyError, match="Unknown preset"):
|
||||
get_preset_weights("nonexistent")
|
||||
|
||||
def test_all_presets_have_both_keys(self):
|
||||
from ml.config.scope import MIXED_MODE_PRESETS
|
||||
for name, preset in MIXED_MODE_PRESETS.items():
|
||||
assert "forge_weight" in preset, f"Preset '{name}' missing forge_weight"
|
||||
assert "ppo_weight" in preset, f"Preset '{name}' missing ppo_weight"
|
||||
@@ -36,7 +36,7 @@ def _engine(personalities=None):
|
||||
|
||||
|
||||
def _run(coro):
|
||||
return asyncio.run(coro)
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -217,7 +217,7 @@ class TestPoliticsCommsChannel:
|
||||
async def h(msg): received_seats.append(s)
|
||||
return h
|
||||
import asyncio
|
||||
h = asyncio.run(make_handler())
|
||||
h = asyncio.get_event_loop().run_until_complete(make_handler())
|
||||
channel.register_handler(seat=seat, handler=h)
|
||||
|
||||
msg = ThreatBroadcast(speaker=0, audience=-1,
|
||||
|
||||
@@ -16,7 +16,7 @@ import pytest
|
||||
|
||||
|
||||
def run(coro):
|
||||
return asyncio.run(coro)
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
<div class="coach-status-items">
|
||||
<div class="coach-status-item" id="status-llm">
|
||||
<span class="status-dot status-unknown"></span>
|
||||
<span class="status-label">Perplexity</span>
|
||||
<span class="status-label">LM Studio</span>
|
||||
<span class="status-value" id="status-llm-value">Checking...</span>
|
||||
</div>
|
||||
<div class="coach-status-item" id="status-embeddings">
|
||||
|
||||
@@ -2841,309 +2841,3 @@
|
||||
height: auto;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
/* ── 17. Stats Dashboard ─────────────────────────────────────── */
|
||||
|
||||
.coll-stats-btn {
|
||||
background: rgba(99,102,241,0.15);
|
||||
border: 1px solid rgba(99,102,241,0.4);
|
||||
color: var(--accent, #6366f1);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
.coll-stats-btn:hover {
|
||||
background: rgba(99,102,241,0.3);
|
||||
border-color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.coll-stats-panel {
|
||||
background: var(--surface, #161b22);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
padding: 20px 24px;
|
||||
margin: 0 0 12px 0;
|
||||
animation: coll-fade-in 0.2s ease;
|
||||
}
|
||||
|
||||
.coll-stats-loading {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.coll-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
/* Summary KPI cards */
|
||||
.coll-stats-summary {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.coll-stat-card {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
background: var(--bg, #0d1117);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.coll-stat-num {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text, #e6edf3);
|
||||
line-height: 1.2;
|
||||
}
|
||||
.coll-stat-label {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
margin-top: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
/* Section headers */
|
||||
.coll-stats-section {
|
||||
border-top: 1px solid var(--border, #30363d);
|
||||
padding-top: 14px;
|
||||
}
|
||||
.coll-stats-section h4 {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0 0 10px 0;
|
||||
}
|
||||
|
||||
/* CMC vertical bar chart */
|
||||
.coll-stat-bar-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
height: 64px;
|
||||
padding-bottom: 20px;
|
||||
position: relative;
|
||||
}
|
||||
.coll-stat-bar-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
justify-content: flex-end;
|
||||
position: relative;
|
||||
}
|
||||
.coll-stat-bar {
|
||||
width: 100%;
|
||||
background: var(--accent, #6366f1);
|
||||
border-radius: 3px 3px 0 0;
|
||||
min-height: 2px;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
.coll-stat-bar-label {
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
}
|
||||
|
||||
/* Horizontal bar rows (colors, types, rarity, categories) */
|
||||
.coll-stat-color-bar,
|
||||
.coll-stat-type-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.coll-stat-color-pip {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.coll-stat-type-label {
|
||||
width: 90px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text, #e6edf3);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coll-stat-hbar-track {
|
||||
flex: 1;
|
||||
height: 10px;
|
||||
background: var(--bg, #0d1117);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--border, #30363d);
|
||||
}
|
||||
.coll-stat-hbar {
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
.coll-stat-hbar-type {
|
||||
background: var(--accent, #6366f1);
|
||||
}
|
||||
.coll-stat-hbar-cat {
|
||||
background: #2ea043;
|
||||
}
|
||||
.coll-stat-hbar-val {
|
||||
width: 36px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-align: right;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Top value cards list */
|
||||
.coll-stat-top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.78rem;
|
||||
padding: 3px 0;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
gap: 8px;
|
||||
}
|
||||
.coll-stat-top-row span:first-child {
|
||||
color: var(--text, #e6edf3);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.coll-stat-top-row span:last-child {
|
||||
color: #3fb950;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.coll-stats-empty {
|
||||
color: var(--text-secondary, #8b949e);
|
||||
font-size: 0.78rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (min-width: 900px) {
|
||||
.coll-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.coll-stats-summary {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1400px) {
|
||||
.coll-stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ── 18. Column Visibility Panel ───────────────────────────── */
|
||||
|
||||
.coll-col-toggle-btn {
|
||||
background: rgba(139,148,158,0.1);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
color: var(--text-secondary, #8b949e);
|
||||
border-radius: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.coll-col-toggle-btn:hover {
|
||||
background: rgba(139,148,158,0.2);
|
||||
color: var(--text, #e6edf3);
|
||||
}
|
||||
|
||||
.coll-col-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 500;
|
||||
background: var(--surface, #161b22);
|
||||
border: 1px solid var(--border, #30363d);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
min-width: 180px;
|
||||
animation: coll-fade-in 0.15s ease;
|
||||
}
|
||||
|
||||
.coll-col-panel-inner {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.coll-col-panel-hdr {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid var(--border, #30363d);
|
||||
}
|
||||
|
||||
.coll-col-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-secondary, #8b949e);
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
padding: 0 4px;
|
||||
line-height: 1;
|
||||
}
|
||||
.coll-col-close:hover { color: var(--text, #e6edf3); }
|
||||
|
||||
.coll-col-toggle-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text, #e6edf3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.coll-col-toggle-item input[type="checkbox"] {
|
||||
accent-color: var(--accent, #6366f1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.coll-col-toggle-item:hover {
|
||||
color: var(--accent, #6366f1);
|
||||
}
|
||||
|
||||
.coll-col-reset {
|
||||
margin-top: 10px;
|
||||
width: 100%;
|
||||
background: rgba(99,102,241,0.1);
|
||||
border: 1px solid rgba(99,102,241,0.3);
|
||||
color: var(--accent, #6366f1);
|
||||
border-radius: 5px;
|
||||
padding: 5px 8px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.coll-col-reset:hover {
|
||||
background: rgba(99,102,241,0.25);
|
||||
}
|
||||
+8
-238
@@ -410,148 +410,6 @@ const Collection = (() => {
|
||||
renderCountBadge();
|
||||
}
|
||||
|
||||
// ── Stats Dashboard ──────────────────────────────────────
|
||||
let statsData = null;
|
||||
let statsOpen = false;
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/api/collection/stats`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
statsData = await res.json();
|
||||
} catch (err) {
|
||||
console.error('[Collection] fetchStats error:', err);
|
||||
statsData = null;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleStats() {
|
||||
statsOpen = !statsOpen;
|
||||
const panel = document.getElementById('coll-stats-panel');
|
||||
if (!panel) return;
|
||||
if (statsOpen) {
|
||||
if (!statsData) {
|
||||
panel.innerHTML = '<div class="coll-stats-loading">Loading stats…</div>';
|
||||
panel.style.display = 'block';
|
||||
fetchStats().then(() => renderStatsPanel());
|
||||
} else {
|
||||
renderStatsPanel();
|
||||
}
|
||||
panel.style.display = 'block';
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
function renderStatsPanel() {
|
||||
const panel = document.getElementById('coll-stats-panel');
|
||||
if (!panel || !statsData) return;
|
||||
const s = statsData;
|
||||
|
||||
// CMC curve bar chart (CSS-only)
|
||||
const cmcMax = Math.max(...Object.values(s.cmcCurve || {}), 1);
|
||||
const cmcBars = [0,1,2,3,4,5,6,7].map(i => {
|
||||
const count = (s.cmcCurve || {})[String(i)] || 0;
|
||||
const pct = Math.round((count / cmcMax) * 100);
|
||||
const label = i === 7 ? '7+' : String(i);
|
||||
return `<div class="coll-stat-bar-col">
|
||||
<div class="coll-stat-bar" style="height:${pct}%" title="${count}"></div>
|
||||
<span class="coll-stat-bar-label">${label}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Color distribution
|
||||
const colorTotal = Math.max(Object.values(s.colorDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const colorBars = ['W','U','B','R','G','C'].map(c => {
|
||||
const count = (s.colorDistribution || {})[c] || 0;
|
||||
const pct = Math.round((count / colorTotal) * 100);
|
||||
const ci = COLOR_MAP[c];
|
||||
return `<div class="coll-stat-color-bar">
|
||||
<span class="coll-stat-color-pip" style="background:${ci.bg};color:${ci.color}">${ci.label}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${ci.bg}"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Type distribution
|
||||
const typeTotal = Math.max(Object.values(s.typeDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const typeBars = Object.entries(s.typeDistribution || {}).sort((a,b) => b[1]-a[1]).map(([t, count]) => {
|
||||
const pct = Math.round((count / typeTotal) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label">${escapeHtml(t)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-type" style="width:${pct}%"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Rarity distribution
|
||||
const rarityOrder = ['common','uncommon','rare','mythic'];
|
||||
const rarityColors = {common:'#706f6f', uncommon:'#859ba6', rare:'#c9a849', mythic:'#d35400'};
|
||||
const rarityTotal = Math.max(Object.values(s.rarityDistribution || {}).reduce((a,b) => a+b, 0), 1);
|
||||
const rarityBars = rarityOrder.map(r => {
|
||||
const count = (s.rarityDistribution || {})[r] || 0;
|
||||
const pct = Math.round((count / rarityTotal) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label" style="color:${rarityColors[r] || '#ccc'}">${r.charAt(0).toUpperCase() + r.slice(1)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar" style="width:${pct}%;background:${rarityColors[r] || '#555'}"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Top value cards
|
||||
const topCards = (s.topValueCards || []).map(c =>
|
||||
`<div class="coll-stat-top-row"><span>${escapeHtml(c.name)}</span><span>$${Number(c.price).toFixed(2)}</span></div>`
|
||||
).join('');
|
||||
|
||||
// Category/role coverage
|
||||
const catEntries = Object.entries(s.categoryDistribution || {}).sort((a,b) => b[1]-a[1]).slice(0, 12);
|
||||
const catMax = Math.max(...catEntries.map(e => e[1]), 1);
|
||||
const catBars = catEntries.map(([cat, count]) => {
|
||||
const pct = Math.round((count / catMax) * 100);
|
||||
return `<div class="coll-stat-type-row">
|
||||
<span class="coll-stat-type-label">${escapeHtml(cat)}</span>
|
||||
<div class="coll-stat-hbar-track"><div class="coll-stat-hbar coll-stat-hbar-cat" style="width:${pct}%"></div></div>
|
||||
<span class="coll-stat-hbar-val">${count}</span>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="coll-stats-grid">
|
||||
<div class="coll-stats-summary">
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalCards || 0).toLocaleString()}</div><div class="coll-stat-label">Total Cards</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${(s.totalUniqueCards || 0).toLocaleString()}</div><div class="coll-stat-label">Unique</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">$${(s.totalValue || 0).toLocaleString(undefined,{minimumFractionDigits:2})}</div><div class="coll-stat-label">Total Value</div></div>
|
||||
<div class="coll-stat-card"><div class="coll-stat-num">${s.avgCmc || 0}</div><div class="coll-stat-label">Avg CMC</div></div>
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>CMC Curve</h4>
|
||||
<div class="coll-stat-bar-chart">${cmcBars}</div>
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Color Distribution</h4>
|
||||
${colorBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Type Distribution</h4>
|
||||
${typeBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Rarity</h4>
|
||||
${rarityBars}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Role Coverage</h4>
|
||||
${catBars || '<div class="coll-stats-empty">No categories assigned yet</div>'}
|
||||
</div>
|
||||
<div class="coll-stats-section">
|
||||
<h4>Top Value Cards</h4>
|
||||
${topCards || '<div class="coll-stats-empty">No priced cards</div>'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
async function fetchCardDetail(cardId) {
|
||||
const res = await fetch(`${API_BASE}/api/collection/${cardId}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
@@ -586,8 +444,7 @@ const Collection = (() => {
|
||||
// Pre-load set/keyword caches for filter autocomplete
|
||||
loadSetsCache();
|
||||
loadKeywordsCache();
|
||||
applyColumnVisibility();
|
||||
await fetchCollection();
|
||||
await fetchCollection();
|
||||
}
|
||||
|
||||
/** Adjust table container height based on actual topbar height */
|
||||
@@ -632,17 +489,10 @@ const Collection = (() => {
|
||||
<button class="coll-export-btn" onclick="Collection.openExport()">Export</button>
|
||||
<button class="coll-autoclassify-btn" onclick="Collection.autoClassify()" title="Auto-detect categories (Ramp, Draw, Removal, etc.) for uncategorized cards">Auto-Classify</button>
|
||||
<button class="coll-reenrich-btn" onclick="Collection.reEnrich()" title="Re-fetch all card data from Scryfall">Refresh Data</button>
|
||||
<button class="coll-stats-btn" onclick="Collection.toggleStats()" title="Show collection statistics dashboard">📊 Stats</button>
|
||||
<div style="position:relative">
|
||||
<button class="coll-col-toggle-btn" onclick="Collection.openColumnPanel()" title="Show/hide table columns">☰ Columns</button>
|
||||
<div id="coll-col-panel" class="coll-col-panel" style="display:none"></div>
|
||||
</div>
|
||||
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
|
||||
<a href="deckbuilder.html" class="coll-nav-link" style="color:var(--lab-purple);border-color:var(--lab-purple)">⚔ Deck Builder</a>
|
||||
<a href="deckgenerator.html" class="coll-nav-link" style="color:var(--lab-warning);border-color:var(--lab-warning)">⚡ Auto Gen</a>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Stats Dashboard Panel -->
|
||||
<div id="coll-stats-panel" class="coll-stats-panel" style="display:none"></div>
|
||||
|
||||
<!-- Main -->
|
||||
<div class="coll-main">
|
||||
@@ -971,79 +821,6 @@ const Collection = (() => {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Column Visibility ──────────────────────────────────────
|
||||
function colVis(key) {
|
||||
return state.preferences.visibleColumns.includes(key) ? '' : 'display:none;';
|
||||
}
|
||||
|
||||
function toggleColumn(key) {
|
||||
const cols = state.preferences.visibleColumns;
|
||||
const idx = cols.indexOf(key);
|
||||
if (idx >= 0) {
|
||||
if (cols.length <= 1) return; // keep at least 1 column
|
||||
cols.splice(idx, 1);
|
||||
} else {
|
||||
cols.push(key);
|
||||
}
|
||||
savePreferences();
|
||||
renderTable();
|
||||
applyColumnVisibility();
|
||||
}
|
||||
|
||||
function renderColumnPanel() {
|
||||
const panel = document.getElementById('coll-col-panel');
|
||||
if (!panel) return;
|
||||
const cols = COLUMNS.map(col => {
|
||||
const checked = state.preferences.visibleColumns.includes(col.key) ? 'checked' : '';
|
||||
return `<label class="coll-col-toggle-item">
|
||||
<input type="checkbox" ${checked} onchange="Collection.toggleColumn('${col.key}')">
|
||||
${escapeHtml(col.label)}
|
||||
</label>`;
|
||||
}).join('');
|
||||
panel.innerHTML = `
|
||||
<div class="coll-col-panel-inner">
|
||||
<div class="coll-col-panel-hdr">Columns <button class="coll-col-close" onclick="Collection.closeColumnPanel()">×</button></div>
|
||||
${cols}
|
||||
<button class="coll-col-reset" onclick="Collection.resetColumns()">Reset Defaults</button>
|
||||
</div>`;
|
||||
panel.style.display = 'block';
|
||||
}
|
||||
|
||||
function openColumnPanel() {
|
||||
const panel = document.getElementById('coll-col-panel');
|
||||
if (!panel) return;
|
||||
if (panel.style.display === 'block') {
|
||||
panel.style.display = 'none';
|
||||
} else {
|
||||
renderColumnPanel();
|
||||
}
|
||||
}
|
||||
|
||||
function closeColumnPanel() {
|
||||
const panel = document.getElementById('coll-col-panel');
|
||||
if (panel) panel.style.display = 'none';
|
||||
}
|
||||
|
||||
function resetColumns() {
|
||||
state.preferences.visibleColumns = ['quantity','name','mana_cost','type_line','color_identity','cmc','power_toughness','rarity','set_name','tcg_price','edhrec_rank','salt_score','category','finish'];
|
||||
savePreferences();
|
||||
renderTable();
|
||||
renderColumnPanel();
|
||||
}
|
||||
function applyColumnVisibility() {
|
||||
let styleEl = document.getElementById('coll-col-vis-style');
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
styleEl.id = 'coll-col-vis-style';
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
const hidden = COLUMNS.filter(col => !state.preferences.visibleColumns.includes(col.key));
|
||||
styleEl.textContent = hidden.map(col =>
|
||||
`.coll-th-${col.key}, .coll-td-${col.key} { display: none !important; }`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
|
||||
// ── Table Header ────────────────────────────────────────
|
||||
|
||||
const COLUMNS = [
|
||||
@@ -1066,8 +843,8 @@ const Collection = (() => {
|
||||
];
|
||||
|
||||
function renderTableHeaderHtml() {
|
||||
const visibleCols = COLUMNS.filter(col => state.preferences.visibleColumns.includes(col.key));
|
||||
const headers = visibleCols.map(col => { if (!col.sortable) {
|
||||
const headers = COLUMNS.map(col => {
|
||||
if (!col.sortable) {
|
||||
return `<th class="coll-th coll-th-${col.key}">${col.label}</th>`;
|
||||
}
|
||||
const isActive = state.sortField === col.key;
|
||||
@@ -1096,12 +873,12 @@ const Collection = (() => {
|
||||
}
|
||||
|
||||
if (state.loading) {
|
||||
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">Loading…</td></tr>`;
|
||||
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">Loading…</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.items.length === 0) {
|
||||
tbody.innerHTML = `<tr><td colspan="${state.preferences.visibleColumns.length}" class="coll-empty">
|
||||
tbody.innerHTML = `<tr><td colspan="${COLUMNS.length}" class="coll-empty">
|
||||
<div class="coll-empty-state">
|
||||
<div class="coll-empty-icon">🃏</div>
|
||||
<div>No cards in collection</div>
|
||||
@@ -2322,8 +2099,7 @@ const Collection = (() => {
|
||||
if (footer) footer.style.display = 'none';
|
||||
|
||||
// Refresh collection
|
||||
applyColumnVisibility();
|
||||
await fetchCollection();
|
||||
await fetchCollection();
|
||||
|
||||
} catch (err) {
|
||||
if (statusEl) {
|
||||
@@ -2960,7 +2736,7 @@ const Collection = (() => {
|
||||
// Refresh collection after short delay
|
||||
setTimeout(() => {
|
||||
closeScan();
|
||||
fetchCollection();
|
||||
loadCollection();
|
||||
}, 1500);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.innerHTML = '<div class="coll-scan-error-msg">Failed: ' + escapeHtml(e.message) + '</div>';
|
||||
@@ -3075,11 +2851,5 @@ const Collection = (() => {
|
||||
popoverAddCatFromInput,
|
||||
reEnrich,
|
||||
autoClassify,
|
||||
toggleStats,
|
||||
// Column visibility
|
||||
toggleColumn,
|
||||
openColumnPanel,
|
||||
closeColumnPanel,
|
||||
resetColumns,
|
||||
};
|
||||
})();
|
||||
+1
-103
@@ -1297,106 +1297,4 @@
|
||||
|
||||
.dg-log-error {
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
/* ── Progress Bar (Deck Generation) ──────────────────── */
|
||||
.dg-progress-wrap {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.dg-progress-bar {
|
||||
height: 6px;
|
||||
background: var(--surface-2, #2a2a3a);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dg-progress-fill {
|
||||
height: 100%;
|
||||
width: 0%;
|
||||
background: linear-gradient(90deg, var(--accent, #7c6ff7), #a78bfa);
|
||||
border-radius: 3px;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
.dg-progress-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 6px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.dg-progress-phase {
|
||||
color: var(--text-muted, #999);
|
||||
}
|
||||
|
||||
.dg-progress-elapsed {
|
||||
color: var(--text-muted, #999);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* — Omit Cards Chips ————————————————————————— */
|
||||
.dg-omit-wrap {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border, #333);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-input, #1a1a2e);
|
||||
min-height: 38px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.dg-omit-wrap:focus-within {
|
||||
border-color: var(--accent, #6c5ce7);
|
||||
box-shadow: 0 0 0 2px rgba(108, 92, 231, 0.25);
|
||||
}
|
||||
|
||||
.dg-omit-chips {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.dg-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
background: var(--accent, #6c5ce7);
|
||||
color: #fff;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.dg-chip-x {
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.dg-chip-x:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dg-omit-input {
|
||||
flex: 1 1 120px;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 2px 4px !important;
|
||||
min-width: 120px;
|
||||
outline: none;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.dg-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: var(--text-muted, #999);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
@@ -138,25 +138,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Omit Cards (chips input) -->
|
||||
<div class="dg-panel">
|
||||
<div class="dg-panel-header">
|
||||
<span class="dg-panel-title">Omit Cards</span>
|
||||
</div>
|
||||
<div class="dg-panel-body">
|
||||
<div class="dg-omit-wrap" id="dg-omit-wrap">
|
||||
<div class="dg-omit-chips" id="dg-omit-chips"></div>
|
||||
<input
|
||||
type="text"
|
||||
class="dg-input dg-omit-input"
|
||||
id="dg-omit-input"
|
||||
placeholder="Type a card name and press Enter…"
|
||||
/>
|
||||
</div>
|
||||
<small class="dg-hint">Cards listed here will be excluded from the generated deck.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="dg-actions">
|
||||
<button class="dg-btn dg-btn-preview" id="dg-preview-btn" disabled>
|
||||
@@ -185,15 +166,6 @@
|
||||
<div class="dg-spinner"></div>
|
||||
<div class="dg-loading-text" id="dg-loading-text">Generating deck via Local AI...</div>
|
||||
</div>
|
||||
<div class="dg-progress-wrap" id="dg-progress-wrap" style="display:none">
|
||||
<div class="dg-progress-bar">
|
||||
<div class="dg-progress-fill" id="dg-progress-fill"></div>
|
||||
</div>
|
||||
<div class="dg-progress-meta">
|
||||
<span class="dg-progress-phase" id="dg-progress-phase"></span>
|
||||
<span class="dg-progress-elapsed" id="dg-progress-elapsed">0s elapsed</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="dg-results" id="dg-results" style="display:none">
|
||||
|
||||
+1
-115
@@ -53,95 +53,6 @@ const DeckGenerator = (() => {
|
||||
|
||||
let searchTimeout = null;
|
||||
|
||||
// ── Progress bar state ──────────────────────────────
|
||||
const PROGRESS_PHASES = [
|
||||
{ at: 0, pct: 5, msg: 'Connecting to AI model...' },
|
||||
{ at: 3, pct: 10, msg: 'Resolving commander on Scryfall...' },
|
||||
{ at: 6, pct: 18, msg: 'Building collection summary...' },
|
||||
{ at: 10, pct: 25, msg: 'Sending prompt to LLM...' },
|
||||
{ at: 20, pct: 40, msg: 'AI is building the 99...' },
|
||||
{ at: 35, pct: 55, msg: 'Selecting cards and filling slots...' },
|
||||
{ at: 50, pct: 65, msg: 'Running smart substitution...' },
|
||||
{ at: 70, pct: 78, msg: 'Cross-referencing with collection...' },
|
||||
{ at: 90, pct: 88, msg: 'Almost there — finalizing deck...' },
|
||||
{ at: 110, pct: 93, msg: 'Still working — large model can take a moment...' },
|
||||
];
|
||||
let _progressInterval = null;
|
||||
let _progressStart = 0;
|
||||
|
||||
function startProgress() {
|
||||
_progressStart = Date.now();
|
||||
const wrap = $('dg-progress-wrap');
|
||||
const fill = $('dg-progress-fill');
|
||||
const phase = $('dg-progress-phase');
|
||||
const elapsed = $('dg-progress-elapsed');
|
||||
if (!wrap) return;
|
||||
wrap.style.display = 'block';
|
||||
fill.style.width = '0%';
|
||||
phase.textContent = '';
|
||||
elapsed.textContent = '0s elapsed';
|
||||
let lastPhaseIdx = -1;
|
||||
_progressInterval = setInterval(() => {
|
||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||
elapsed.textContent = secs + 's elapsed';
|
||||
for (let i = PROGRESS_PHASES.length - 1; i >= 0; i--) {
|
||||
if (secs >= PROGRESS_PHASES[i].at) {
|
||||
fill.style.width = PROGRESS_PHASES[i].pct + '%';
|
||||
if (i !== lastPhaseIdx) {
|
||||
phase.textContent = PROGRESS_PHASES[i].msg;
|
||||
logMessage(PROGRESS_PHASES[i].msg, 'info');
|
||||
lastPhaseIdx = i;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopProgress(success) {
|
||||
if (_progressInterval) {
|
||||
clearInterval(_progressInterval);
|
||||
_progressInterval = null;
|
||||
}
|
||||
const fill = $('dg-progress-fill');
|
||||
const phase = $('dg-progress-phase');
|
||||
const elapsed = $('dg-progress-elapsed');
|
||||
if (fill) fill.style.width = success ? '100%' : '0%';
|
||||
if (phase) phase.textContent = success ? 'Done!' : 'Failed';
|
||||
const secs = Math.floor((Date.now() - _progressStart) / 1000);
|
||||
if (elapsed) elapsed.textContent = secs + 's total';
|
||||
}
|
||||
|
||||
// ── Omit Cards (chips) ─────────────────────────────
|
||||
let omitCards = [];
|
||||
|
||||
function addOmitCard(name) {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
if (omitCards.some(c => c.toLowerCase() === trimmed.toLowerCase())) return;
|
||||
omitCards.push(trimmed);
|
||||
renderOmitChips();
|
||||
}
|
||||
|
||||
function removeOmitCard(idx) {
|
||||
omitCards.splice(idx, 1);
|
||||
renderOmitChips();
|
||||
}
|
||||
|
||||
function renderOmitChips() {
|
||||
const container = $('dg-omit-chips');
|
||||
if (!container) return;
|
||||
container.innerHTML = omitCards.map((name, i) =>
|
||||
'<span class="dg-chip">' +
|
||||
escHtml(name) +
|
||||
'<span class="dg-chip-x" data-idx="' + i + '">×</span>' +
|
||||
'</span>'
|
||||
).join('');
|
||||
container.querySelectorAll('.dg-chip-x').forEach(el => {
|
||||
el.addEventListener('click', () => removeOmitCard(parseInt(el.dataset.idx, 10)));
|
||||
});
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ── Init ────────────────────────────────────────────────
|
||||
@@ -207,22 +118,6 @@ const DeckGenerator = (() => {
|
||||
$('dg-export-dck').addEventListener('click', () => exportDeck('dck'));
|
||||
$('dg-export-moxfield').addEventListener('click', () => exportMoxfield());
|
||||
$('dg-export-shopping').addEventListener('click', () => exportShopping());
|
||||
|
||||
// Omit cards chips input
|
||||
const omitInput = $('dg-omit-input');
|
||||
if (omitInput) {
|
||||
omitInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addOmitCard(omitInput.value);
|
||||
omitInput.value = '';
|
||||
}
|
||||
});
|
||||
}
|
||||
const omitWrap = $('dg-omit-wrap');
|
||||
if (omitWrap) {
|
||||
omitWrap.addEventListener('click', () => omitInput && omitInput.focus());
|
||||
}
|
||||
}
|
||||
|
||||
function checkQueryParams() {
|
||||
@@ -243,7 +138,7 @@ const DeckGenerator = (() => {
|
||||
target_bracket: state.targetBracket,
|
||||
budget_usd: budget,
|
||||
budget_mode: $('dg-budget-mode').value,
|
||||
omit_cards: omitCards.slice(),
|
||||
omit_cards: [],
|
||||
use_collection: $('dg-use-collection').checked,
|
||||
run_substitution: $('dg-substitution').checked,
|
||||
model: $('dg-model').value,
|
||||
@@ -345,12 +240,6 @@ const DeckGenerator = (() => {
|
||||
}
|
||||
|
||||
$('dg-preview-btn').disabled = false;
|
||||
|
||||
// Auto-fill deck name if empty
|
||||
const deckNameInput = $('dg-deck-name');
|
||||
if (deckNameInput && !deckNameInput.value.trim()) {
|
||||
deckNameInput.value = 'Auto - ' + cmdr.name;
|
||||
}
|
||||
}
|
||||
|
||||
function clearCommander() {
|
||||
@@ -395,7 +284,6 @@ const DeckGenerator = (() => {
|
||||
logMessage('Building request body with commander: <b>' + state.commander.name + '</b>', 'info');
|
||||
|
||||
setLoading(true, 'Generating deck via Local AI...');
|
||||
startProgress();
|
||||
|
||||
const body = buildRequestBody();
|
||||
logMessage('Sending request to AI model... this may take a moment.', 'info');
|
||||
@@ -419,12 +307,10 @@ const DeckGenerator = (() => {
|
||||
const stats = state.previewResult.stats || {};
|
||||
const cardCount = (stats.total_cards || 0);
|
||||
logMessage('Deck generated: <b>' + cardCount + ' cards</b> | $' + (stats.total_price_usd || 0).toFixed(2) + ' estimated', 'success');
|
||||
stopProgress(true);
|
||||
renderResults(state.previewResult);
|
||||
|
||||
toast('Deck generated: ' + (stats.total_cards || 0) + ' cards | $' + (stats.total_price_usd || 0), 'success');
|
||||
} catch (e) {
|
||||
stopProgress(false);
|
||||
toast(e.message || 'Generation failed', 'error');
|
||||
logMessage('Error: ' + (e.message || 'Unknown error'), 'error');
|
||||
setLoading(false);
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@
|
||||
<div class="deepseek-config-row">
|
||||
<div class="form-group sim-field-grow">
|
||||
<label class="form-label" for="ds-api-base">API Endpoint</label>
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://localhost:11434" placeholder="http://localhost:11434" />
|
||||
<input type="text" id="ds-api-base" class="form-input" value="http://192.168.0.240:11434" placeholder="http://192.168.0.240:11434" />
|
||||
</div>
|
||||
<div class="form-group sim-field-auto">
|
||||
<label class="form-label" for="ds-model">Model</label>
|
||||
|
||||
Reference in new issue
Block a user