Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d31b8467d0 | ||
|
|
7a40b5deb8 | ||
|
|
af8970f3a8 | ||
|
|
53fd1c9257 | ||
|
|
6ead75f96b | ||
|
|
535bdd7c70 | ||
|
|
d98e6331e9 | ||
|
|
2b3196267f | ||
|
|
433e8bc4d7 | ||
|
|
d74dd0a6bf | ||
|
|
8037802dff | ||
|
|
ca6c23a881 | ||
|
|
a81772c68a | ||
|
|
99ca498b88 | ||
|
|
395ad06cdd | ||
|
|
e4a6ce644d | ||
|
|
5411fd6bf6 |
No files matched your search
+17
-31
@@ -1,36 +1,22 @@
|
||||
# Commander AI Lab - Environment Variables
|
||||
# Copy this file to .env and fill in your API keys.
|
||||
# .env is gitignored and will NOT be committed.
|
||||
# Commander AI Lab — Environment Variables
|
||||
# Copy this file to .env and fill in your values.
|
||||
# start-lab.bat will auto-load .env if it exists.
|
||||
|
||||
# Ximilar API Key (card scanner / visual AI recognition)
|
||||
# Get yours at: https://app.ximilar.com/
|
||||
XIMILAR_API_KEY=your-ximilar-api-key-here
|
||||
|
||||
# Anthropic API Key (AI deck generation & coaching with Claude Opus)
|
||||
# Get yours at: https://console.anthropic.com/settings/keys
|
||||
# ── API Keys ──────────────────────────────────────────────────────
|
||||
ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
# Anthropic model for deck generation and coaching (Claude Opus recommended)
|
||||
ANTHROPIC_MODEL=claude-opus-4-20250514
|
||||
# Coach chat provider: "local" (Ollama) or "anthropic" (Claude API)
|
||||
COACH_PROVIDER=anthropic
|
||||
XIMILAR_API_KEY=your-ximilar-key-here
|
||||
|
||||
# CORS allowed origins (comma-separated)
|
||||
# Default: http://localhost:5173,http://localhost:8080
|
||||
# Add your LAN IP if accessing from another machine on your network
|
||||
ALLOWED_ORIGINS=http://localhost:5173,http://localhost:8080
|
||||
# ── Forge JAR Path ────────────────────────────────────────────────
|
||||
# Point this to your Forge GUI desktop fat JAR.
|
||||
FORGE_JAR_PATH=D:/ForgeCommander/forge-repo/forge-gui-desktop/target/forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
|
||||
# ── Dual-Model AI Brain ─────────────────────────────────────
|
||||
# Primary brain: GPT-OSS 20B (turn planner / decision maker)
|
||||
BRAIN_API_BASE=http://localhost:11434
|
||||
BRAIN_MODEL=gpt-oss:20b
|
||||
BRAIN_TEMPERATURE=0.3
|
||||
BRAIN_MAX_TOKENS=1024
|
||||
BRAIN_TIMEOUT=120.0
|
||||
# IMPORTANT: Forge MUST be launched from the forge-gui directory (not forge-gui-desktop/target).
|
||||
# The JAR looks for res/ relative to its working directory. Without this, you get
|
||||
# ExceptionInInitializerError on startup.
|
||||
FORGE_WORKING_DIR=D:/ForgeCommander/forge-repo/forge-gui
|
||||
|
||||
# Validator brain: DeepSeek-R1-Distill-Qwen-14B (legality + explanation)
|
||||
VALIDATOR_ENABLED=true
|
||||
VALIDATOR_API_BASE=http://localhost:11434
|
||||
VALIDATOR_MODEL=deepseek-r1:14b
|
||||
VALIDATOR_TEMPERATURE=0.0
|
||||
VALIDATOR_MAX_TOKENS=2048
|
||||
VALIDATOR_TIMEOUT=120.0
|
||||
# ── Deck Paths ────────────────────────────────────────────────────
|
||||
# Directory containing your precon .dck files.
|
||||
# Forge reads deck files directly from this folder.
|
||||
PRECON_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||
FORGE_DECKS_DIR=D:\ForgeCommander\commander-ai-lab\precon-decks
|
||||
-31
@@ -38,34 +38,3 @@ Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Precon decks — runtime data, downloaded locally by Forge/scripts
|
||||
# Do not commit .dck files; they are large and machine-generated
|
||||
precon-decks/*.dck
|
||||
precon-decks/precon-index.json
|
||||
|
||||
# Phase 0: Local Scryfall card image cache (~1.5-2GB)
|
||||
# Downloaded by scripts/download_card_images.py — never commit
|
||||
static/card-images/
|
||||
|
||||
# Phase 0: SQLite image index (generated by scripts/build_image_index.py)
|
||||
data/card_image_index.db
|
||||
|
||||
# Phase 6: AI decision logs and training sets (large runtime data)
|
||||
data/decision_logs/
|
||||
data/game_results.jsonl
|
||||
data/training_sets/
|
||||
|
||||
# Unity generated / local config files
|
||||
unity-client/Packages/packages-lock.json
|
||||
unity-client/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-client/ProjectSettings/EditorSettings.asset
|
||||
unity-client/ProjectSettings/ShaderGraphSettings.asset
|
||||
unity-commander-ui/Packages/packages-lock.json
|
||||
unity-commander-ui/ProjectSettings/EditorBuildSettings.asset
|
||||
unity-commander-ui/ProjectSettings/EditorSettings.asset
|
||||
|
||||
# Third-party Unity assets — installed locally via Unity Package Manager / Asset Store
|
||||
# These are large binary asset packs; each dev installs from their own license
|
||||
unity-client/Assets/Eric VFX Studio/
|
||||
unity-client/Assets/TcgEngine/
|
||||
@@ -1 +0,0 @@
|
||||
# Phase 2: Dual-Brain AI Decision Engine
|
||||
@@ -1,5 +0,0 @@
|
||||
# Phase 6 - decision_logger.py
|
||||
# Logs per-decision: game_id, turn, phase, player_id, personality,
|
||||
# brain_used, prompt, raw_response, parsed_action, fallback_used
|
||||
# Configurable via LOG_AI_DECISIONS env var
|
||||
# See issue #173
|
||||
@@ -1,15 +0,0 @@
|
||||
# Phase 2 - game_orchestrator.py *** CORE PRIORITY ***
|
||||
# GameAIOrchestrator: select_brain(), assign_personalities(), get_action()
|
||||
# Brains: DeepSeek-R1-14B (complex) + GPT-OSS-20B (tactical)
|
||||
# ChromaDB RAG for per-player card knowledge
|
||||
# See issue #169
|
||||
|
||||
PERSONALITY_POOL = [
|
||||
"aggressive -- attack early and often, prioritize players with lowest life totals",
|
||||
"control -- counter key spells, hold mana open, only act when clearly advantageous",
|
||||
"combo -- develop mana and card advantage quietly, avoid conflict until ready to win",
|
||||
"political -- make deals, attack whoever is ahead, never fully commit to one target",
|
||||
"pillowfort -- build defensive pieces, let others fight, win slowly through attrition",
|
||||
"stompy -- play the biggest threats as fast as possible, ignore table politics",
|
||||
"tempo -- disrupt opponents early, trade efficiently, stay ahead on board",
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
# Phase 0: Card image cache and data enrichment
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCache, ImageCacheResult
|
||||
from commander_ai_lab.cards.card_data_enricher import enrich_card, enrich_card_list, enrich_game_state
|
||||
|
||||
__all__ = [
|
||||
"get_image_cache",
|
||||
"ImageCache",
|
||||
"ImageCacheResult",
|
||||
"enrich_card",
|
||||
"enrich_card_list",
|
||||
"enrich_game_state",
|
||||
]
|
||||
@@ -1,116 +0,0 @@
|
||||
"""
|
||||
Phase 0 — card_data_enricher.py
|
||||
|
||||
Injects art_url and art_url_back into card data dicts/DTOs
|
||||
before they are broadcast to Unity via WebSocket.
|
||||
|
||||
Used by:
|
||||
- Phase 1 game_state_parser.py (enriches cards in GameStateDTO)
|
||||
- Phase 3 WebSocket handler (enriches full game state before broadcast)
|
||||
|
||||
Lookup chain per card:
|
||||
1. scryfall_id present -> ImageCache.lookup_by_id()
|
||||
2. scryfall_id absent -> ImageCache.lookup_by_name() (Forge card name)
|
||||
3. Both fail -> Scryfall CDN fallback URL
|
||||
4. CDN fallback absent -> empty string (Unity shows card back texture)
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache, ImageCacheResult
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.card_data_enricher")
|
||||
|
||||
# Scryfall CDN fallback when both local cache and DB are unavailable
|
||||
_CDN_FRONT = "https://cards.scryfall.io/normal/front/{d1}/{d2}/{sid}.jpg"
|
||||
_CDN_BACK = "https://cards.scryfall.io/normal/back/{d1}/{d2}/{sid}.jpg"
|
||||
|
||||
|
||||
def _cdn_fallback_urls(scryfall_id: str) -> tuple[str, str | None]:
|
||||
if not scryfall_id:
|
||||
return "", None
|
||||
d1 = scryfall_id[0]
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
front = _CDN_FRONT.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
back = _CDN_BACK.format(d1=d1, d2=d2, sid=scryfall_id)
|
||||
return front, back
|
||||
|
||||
|
||||
def enrich_card(card: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Inject art_url and art_url_back into a single card dict.
|
||||
Mutates in place AND returns the card for convenience.
|
||||
|
||||
Input card dict is expected to have at minimum:
|
||||
- 'name': str (card name from Forge)
|
||||
- 'scryfall_id': str (optional but preferred)
|
||||
|
||||
After enrichment the card will have:
|
||||
- 'art_url': str front face image URL
|
||||
- 'art_url_back': str|None back face image URL (DFCs only)
|
||||
- 'has_back': bool
|
||||
"""
|
||||
# Skip if already enriched
|
||||
if card.get("art_url"):
|
||||
return card
|
||||
|
||||
cache = get_image_cache()
|
||||
result: ImageCacheResult | None = None
|
||||
|
||||
scryfall_id = card.get("scryfall_id", "")
|
||||
if scryfall_id:
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
|
||||
if result is None:
|
||||
name = card.get("name", "")
|
||||
if name:
|
||||
result = cache.lookup_by_name(name)
|
||||
|
||||
if result is not None:
|
||||
card["art_url"] = result.front_url
|
||||
card["art_url_back"] = result.back_url
|
||||
card["has_back"] = result.has_back
|
||||
else:
|
||||
# Hard fallback: build CDN URL from scryfall_id if available
|
||||
front, back = _cdn_fallback_urls(scryfall_id)
|
||||
card["art_url"] = front
|
||||
card["art_url_back"] = back if back else None
|
||||
card["has_back"] = False
|
||||
if not front:
|
||||
log.warning("No art URL resolved for card: %s (id=%s)", card.get("name"), scryfall_id)
|
||||
|
||||
return card
|
||||
|
||||
|
||||
def enrich_card_list(cards: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Enrich a list of card dicts. Mutates in place."""
|
||||
for card in cards:
|
||||
enrich_card(card)
|
||||
return cards
|
||||
|
||||
|
||||
def enrich_game_state(state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Enrich all card lists in a full GameStateDTO dict.
|
||||
Walks all players and all zones: hand, battlefield, graveyard, exile, command_zone.
|
||||
Mutates in place AND returns state.
|
||||
"""
|
||||
zone_keys = ("hand", "battlefield", "graveyard", "exile", "command_zone")
|
||||
|
||||
for player in state.get("players", []):
|
||||
for zone in zone_keys:
|
||||
cards = player.get(zone)
|
||||
if isinstance(cards, list):
|
||||
enrich_card_list(cards)
|
||||
|
||||
# Also enrich the stack
|
||||
for stack_item in state.get("stack", []):
|
||||
card = stack_item.get("card")
|
||||
if isinstance(card, dict):
|
||||
enrich_card(card)
|
||||
|
||||
return state
|
||||
@@ -1,167 +0,0 @@
|
||||
"""
|
||||
Phase 0 — image_cache.py
|
||||
|
||||
SQLite-backed card image lookup service.
|
||||
|
||||
Provides:
|
||||
ImageCache — sync lookup (used by download scripts, CLI tools)
|
||||
AsyncImageCache — async lookup (used by FastAPI request handlers)
|
||||
|
||||
Lookup priority:
|
||||
1. SQLite index by scryfall_id (exact, fast)
|
||||
2. SQLite index by card name (case-insensitive, Forge card name format)
|
||||
3. CDN fallback URL (scryfall.io) — no local file, but still a valid URL
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.cards.image_cache")
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
||||
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
|
||||
STATIC_BASE_URL = "/static/card-images"
|
||||
|
||||
# Scryfall CDN URL pattern for fallback
|
||||
# Format: https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg
|
||||
# where d1/d2 are first two hex chars of the UUID
|
||||
def _cdn_url(scryfall_id: str, face: str = "front") -> str:
|
||||
"""Build a Scryfall CDN URL for a card face. Used as fallback."""
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
return f"https://cards.scryfall.io/normal/{face}/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
|
||||
|
||||
class ImageCacheResult:
|
||||
"""Result of a card image lookup."""
|
||||
__slots__ = ("scryfall_id", "name_raw", "front_url", "back_url", "has_back", "is_local")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
scryfall_id: str,
|
||||
name_raw: str,
|
||||
front_url: str,
|
||||
back_url: Optional[str],
|
||||
has_back: bool,
|
||||
is_local: bool,
|
||||
):
|
||||
self.scryfall_id = scryfall_id
|
||||
self.name_raw = name_raw
|
||||
self.front_url = front_url
|
||||
self.back_url = back_url
|
||||
self.has_back = has_back
|
||||
self.is_local = is_local
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"scryfall_id": self.scryfall_id,
|
||||
"name": self.name_raw,
|
||||
"art_url": self.front_url,
|
||||
"art_url_back": self.back_url,
|
||||
"has_back": self.has_back,
|
||||
"is_local": self.is_local,
|
||||
}
|
||||
|
||||
|
||||
class ImageCache:
|
||||
"""Synchronous SQLite-backed card image cache."""
|
||||
|
||||
def __init__(self, db_path: Path = DB_PATH, base_url: str = STATIC_BASE_URL):
|
||||
self._db_path = db_path
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
if self._conn is None:
|
||||
if not self._db_path.exists():
|
||||
log.warning(
|
||||
"Image index DB not found at %s. Run scripts/build_image_index.py first.",
|
||||
self._db_path
|
||||
)
|
||||
return None
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
return self._conn
|
||||
|
||||
def _row_to_result(self, row: sqlite3.Row) -> ImageCacheResult:
|
||||
scryfall_id = row["scryfall_id"]
|
||||
front_path = row["front_path"]
|
||||
back_path = row["back_path"]
|
||||
has_back = bool(row["has_back"])
|
||||
|
||||
if front_path:
|
||||
front_url = f"{self._base_url}/{scryfall_id}_front.jpg"
|
||||
back_url = f"{self._base_url}/{scryfall_id}_back.jpg" if has_back else None
|
||||
is_local = True
|
||||
else:
|
||||
# CDN fallback
|
||||
front_url = _cdn_url(scryfall_id, "front")
|
||||
back_url = _cdn_url(scryfall_id, "back") if has_back else None
|
||||
is_local = False
|
||||
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw=row["name_raw"],
|
||||
front_url=front_url,
|
||||
back_url=back_url,
|
||||
has_back=has_back,
|
||||
is_local=is_local,
|
||||
)
|
||||
|
||||
def lookup_by_id(self, scryfall_id: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by Scryfall UUID."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE scryfall_id = ?", (scryfall_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return self._cdn_fallback(scryfall_id)
|
||||
|
||||
def lookup_by_name(self, name: str) -> Optional[ImageCacheResult]:
|
||||
"""Look up card by name (case-insensitive). Returns most recent printing."""
|
||||
conn = self._get_conn()
|
||||
if conn is None:
|
||||
return None
|
||||
row = conn.execute(
|
||||
"SELECT * FROM card_images WHERE name = ? LIMIT 1",
|
||||
(name.lower().strip(),)
|
||||
).fetchone()
|
||||
if row:
|
||||
return self._row_to_result(row)
|
||||
return None
|
||||
|
||||
def _cdn_fallback(self, scryfall_id: str) -> ImageCacheResult:
|
||||
"""Return CDN URLs when local cache is unavailable."""
|
||||
return ImageCacheResult(
|
||||
scryfall_id=scryfall_id,
|
||||
name_raw="",
|
||||
front_url=_cdn_url(scryfall_id, "front"),
|
||||
back_url=None,
|
||||
has_back=False,
|
||||
is_local=False,
|
||||
)
|
||||
|
||||
def close(self):
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
|
||||
|
||||
# Module-level singleton for use by FastAPI route handlers
|
||||
_cache: Optional[ImageCache] = None
|
||||
|
||||
|
||||
def get_image_cache() -> ImageCache:
|
||||
"""Get the module-level ImageCache singleton."""
|
||||
global _cache
|
||||
if _cache is None:
|
||||
_cache = ImageCache()
|
||||
return _cache
|
||||
@@ -1 +0,0 @@
|
||||
# Phase 1: Forge subprocess bridge
|
||||
@@ -1,3 +0,0 @@
|
||||
# Phase 6 - game_result_logger.py
|
||||
# Captures winner, turn count, elimination order on Forge game end
|
||||
# See issue #173
|
||||
@@ -1,3 +0,0 @@
|
||||
# Phase 1 - game_state_parser.py
|
||||
# Converts Forge stdout text -> canonical GameStateDTO JSON
|
||||
# See issue #168
|
||||
@@ -1,4 +0,0 @@
|
||||
# Phase 1 - subprocess_manager.py
|
||||
# ForgeGameSession + ForgeSubprocessManager
|
||||
# Drives interactive Commander games via Forge stdin/stdout pipe
|
||||
# See issue #168
|
||||
@@ -1 +0,0 @@
|
||||
# Phase 6: ML Training Integration
|
||||
@@ -1,4 +0,0 @@
|
||||
# Phase 6 - training_data_builder.py
|
||||
# Converts decision_log + game_result -> (state, action, reward) tuples
|
||||
# Feeds existing PPO pipeline
|
||||
# See issue #173
|
||||
@@ -1,2 +0,0 @@
|
||||
# Runtime data directory
|
||||
# card_image_index.db, decision_logs/, game_results.jsonl, training_sets/
|
||||
@@ -0,0 +1,785 @@
|
||||
# Commander-AI-Lab — Unity TCGEngine Commander Board: Implementation Plan (v5)
|
||||
|
||||
> **Architecture Principle:** Forge = Rules Authority · FastAPI = Hub · TCGEngine Unity = Renderer · Dual-Brain AI = Decision Engine
|
||||
|
||||
---
|
||||
|
||||
## System Architecture Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ UNITY (TCGEngine — 2D Board) │
|
||||
│ PlayerArea x4: Hand | Library | Graveyard | Exile | Command │
|
||||
│ Battlefield zones per player · Card art from local cache │
|
||||
│ GameNetworkManager.cs ←── WebSocket ──► FastAPI │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FASTAPI (commander-ai-lab) │
|
||||
│ /ws/game — WebSocket relay │
|
||||
│ /game/action — receive player/AI action, forward to Forge │
|
||||
│ ForgeSubprocessManager — stdin/stdout pipe to Forge JVM │
|
||||
│ CardEnricher — injects art_url from local image cache │
|
||||
│ AIOrchestrator — routes decisions to DeepSeek-R1 or GPT-OSS │
|
||||
│ ChromaDB RAG — card knowledge scoped per player deck │
|
||||
│ ai_decisions.jsonl — logs every AI decision (PPO dataset) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
▲ ▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ FORGE (Java — Headless Rules Engine) │
|
||||
│ Full Commander rules: tax, 21-commander-damage, 40 life │
|
||||
│ 4-player turn queue · Zone redirects · State-based actions │
|
||||
│ Emits game state JSON to stdout · Receives actions via stdin │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Dual-Brain AI Decision Flow
|
||||
|
||||
This is the **core feature** of Commander-AI-Lab. Every AI player decision runs through this pipeline:
|
||||
|
||||
```
|
||||
Forge emits GameStateDTO (JSON)
|
||||
└─► FastAPI detects active player is AI
|
||||
└─► select_brain(phase, stack_size)
|
||||
├─ DeepSeek-R1-14B → combat, stack decisions, removal
|
||||
└─ GPT-OSS-20B → main phase, ramp, card advantage
|
||||
└─► ChromaDB.query(player_deck_cards, top_k=5)
|
||||
└─► build_prompt(state, rag_context, personality)
|
||||
└─► LLM.generate(prompt)
|
||||
└─► parse_action_json(response)
|
||||
├─ validate vs legal_actions[]
|
||||
├─ log to ai_decisions.jsonl
|
||||
└─► Forge stdin ← chosen action
|
||||
```
|
||||
|
||||
### Brain Selector (`ai/brain_selector.py`)
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class Brain(Enum):
|
||||
DEEPSEEK = "deepseek-r1:14b"
|
||||
GPT_OSS = "gpt-oss-20b" # adjust to your Ollama model tag
|
||||
|
||||
def select_brain(phase: str, stack_size: int) -> Brain:
|
||||
"""
|
||||
DeepSeek-R1: combat math, instant-speed stack decisions, targeted removal.
|
||||
GPT-OSS: sorcery-speed strategy, ramp sequencing, card selection.
|
||||
"""
|
||||
combat_phases = {"declare_attackers", "declare_blockers", "combat_damage"}
|
||||
if phase in combat_phases or stack_size > 0:
|
||||
return Brain.DEEPSEEK
|
||||
return Brain.GPT_OSS
|
||||
```
|
||||
|
||||
### Prompt Builder (`ai/prompt_builder.py`)
|
||||
|
||||
```python
|
||||
from chromadb import Client
|
||||
from .personality import get_personality_prompt
|
||||
|
||||
chroma = Client()
|
||||
card_collection = chroma.get_collection("mtg_cards")
|
||||
|
||||
def build_prompt(state: dict, player_id: str, personality: str) -> str:
|
||||
# Pull card knowledge scoped to THIS player's deck
|
||||
player_cards = [c["name"] for c in state["players"][player_id]["library"]]
|
||||
rag_results = card_collection.query(
|
||||
query_texts=player_cards[:10],
|
||||
n_results=5
|
||||
)
|
||||
card_context = "\n".join(rag_results["documents"][0])
|
||||
|
||||
personality_prompt = get_personality_prompt(personality)
|
||||
|
||||
return f"""You are an AI playing Magic: The Gathering Commander.
|
||||
{personality_prompt}
|
||||
|
||||
## Current Game State
|
||||
- Phase: {state['phase']}
|
||||
- Active Player: {player_id}
|
||||
- Life Totals: {state['life_totals']}
|
||||
- Commander Damage Dealt: {state['commander_damage']}
|
||||
- Stack: {state['stack']}
|
||||
- Your Hand: {[c['name'] for c in state['players'][player_id]['hand']]}
|
||||
- Battlefield: {[c['name'] for c in state['players'][player_id]['battlefield']]}
|
||||
- Available Mana: {state['players'][player_id]['mana_pool']}
|
||||
|
||||
## Legal Actions
|
||||
{state['legal_actions']}
|
||||
|
||||
## Card Knowledge (RAG Context)
|
||||
{card_context}
|
||||
|
||||
Respond with a single JSON object:
|
||||
{{"action": "<action_type>", "card_id": "<id_or_null>", "targets": [], "reasoning": "<brief>"}}
|
||||
"""
|
||||
```
|
||||
|
||||
### Personality System (`ai/personality.py`)
|
||||
|
||||
```python
|
||||
import random
|
||||
|
||||
PERSONALITIES = {
|
||||
"aggro_beatdown": "You play aggressively. Attack every turn. Remove blockers. Win through combat damage as fast as possible.",
|
||||
"control_lockdown": "You play control. Counter key threats. Hold up mana for interaction. Win only when the board is locked.",
|
||||
"combo_enabler": "You dig for your combo pieces. Every decision is evaluated by how it advances your win condition.",
|
||||
"political_diplomat": "You form alliances. Attack the leader. Make deals. Never be the archenemy.",
|
||||
"voltron_commander": "Everything buffs your commander. Protect it at all costs. Win through commander damage.",
|
||||
"stax_prison": "You tax and restrict opponents. Deploy hate pieces. Win through attrition and resource denial.",
|
||||
"tempo_value": "You trade efficiently. Generate card advantage. Win by being ahead on resources.",
|
||||
"chaos_wildcard": "You make unpredictable decisions. Prioritize fun and chaos over optimal play.",
|
||||
}
|
||||
|
||||
def assign_personalities(player_ids: list[str], deck_archetypes: dict) -> dict[str, str]:
|
||||
"""
|
||||
Assign a personality per AI player at game creation.
|
||||
80% chance: archetype-matched personality.
|
||||
20% chance: random wildcard for unpredictability.
|
||||
"""
|
||||
assignments = {}
|
||||
for pid in player_ids:
|
||||
archetype = deck_archetypes.get(pid, "tempo_value")
|
||||
if random.random() < 0.20:
|
||||
assignments[pid] = random.choice(list(PERSONALITIES.keys()))
|
||||
else:
|
||||
assignments[pid] = archetype
|
||||
return assignments
|
||||
|
||||
def get_personality_prompt(personality: str) -> str:
|
||||
return f"## Your Playstyle\n{PERSONALITIES.get(personality, PERSONALITIES['tempo_value'])}"
|
||||
```
|
||||
|
||||
### AI Orchestrator (`ai/ai_orchestrator.py`)
|
||||
|
||||
```python
|
||||
import json, time, logging
|
||||
from pathlib import Path
|
||||
from .brain_selector import select_brain, Brain
|
||||
from .prompt_builder import build_prompt
|
||||
import ollama
|
||||
|
||||
LOG_PATH = Path("logs/ai_decisions.jsonl")
|
||||
LOG_PATH.parent.mkdir(exist_ok=True)
|
||||
|
||||
async def get_ai_action(state: dict, player_id: str, personality: str) -> dict:
|
||||
brain = select_brain(state["phase"], len(state["stack"]))
|
||||
prompt = build_prompt(state, player_id, personality)
|
||||
t_start = time.time()
|
||||
|
||||
try:
|
||||
response = ollama.chat(
|
||||
model=brain.value,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
options={"temperature": 0.3, "num_predict": 256}
|
||||
)
|
||||
raw = response["message"]["content"]
|
||||
# Extract JSON from response (model may wrap in markdown)
|
||||
action = json.loads(raw[raw.find("{"):raw.rfind("}")+1])
|
||||
except Exception as e:
|
||||
logging.warning(f"AI parse failed ({brain.value}): {e}. Passing turn.")
|
||||
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "parse_error"}
|
||||
|
||||
# Validate against Forge legal actions
|
||||
if action.get("action") not in [a["type"] for a in state["legal_actions"]]:
|
||||
logging.warning(f"Illegal action returned by {brain.value}. Falling back to pass.")
|
||||
action = {"action": "pass", "card_id": None, "targets": [], "reasoning": "illegal_action"}
|
||||
|
||||
# Log for PPO training dataset
|
||||
log_entry = {
|
||||
"timestamp": time.time(),
|
||||
"player_id": player_id,
|
||||
"brain": brain.value,
|
||||
"personality": personality,
|
||||
"phase": state["phase"],
|
||||
"action": action,
|
||||
"reasoning": action.get("reasoning", ""),
|
||||
"latency_ms": round((time.time() - t_start) * 1000),
|
||||
}
|
||||
with open(LOG_PATH, "a") as f:
|
||||
f.write(json.dumps(log_entry) + "\n")
|
||||
|
||||
return action
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Local Card Image Cache
|
||||
|
||||
**Build this first.** Every downstream system depends on local art URLs.
|
||||
|
||||
### Step 1 — Bulk Download (`scripts/download_card_images.py`)
|
||||
|
||||
```python
|
||||
import requests, json, os
|
||||
from pathlib import Path
|
||||
from tqdm import tqdm
|
||||
|
||||
BULK_URL = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
IMAGE_DIR = Path("static/card-images")
|
||||
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def download_images():
|
||||
# Get bulk data URL
|
||||
meta = requests.get(BULK_URL).json()
|
||||
cards = requests.get(meta["download_uri"], stream=True).json()
|
||||
|
||||
for card in tqdm(cards, desc="Downloading card images"):
|
||||
image_uris = card.get("image_uris") or {}
|
||||
url = image_uris.get("normal")
|
||||
if not url:
|
||||
continue # skip DFCs without top-level image_uris
|
||||
|
||||
safe_name = card["name"].replace("/", "_").replace(" ", "_")
|
||||
set_code = card.get("set", "unknown")
|
||||
filename = IMAGE_DIR / set_code / f"{safe_name}_{card['collector_number']}.jpg"
|
||||
filename.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if filename.exists():
|
||||
continue # already cached
|
||||
|
||||
img_data = requests.get(url, timeout=10).content
|
||||
filename.write_bytes(img_data)
|
||||
|
||||
if __name__ == "__main__":
|
||||
download_images()
|
||||
```
|
||||
|
||||
### Step 2 — Build SQLite Index (`scripts/build_image_index.py`)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
IMAGE_DIR = Path("static/card-images")
|
||||
DB_PATH = Path("data/card_images.db")
|
||||
DB_PATH.parent.mkdir(exist_ok=True)
|
||||
|
||||
def build_index():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card_images (
|
||||
name TEXT PRIMARY KEY,
|
||||
path TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
for img_path in IMAGE_DIR.rglob("*.jpg"):
|
||||
card_name = img_path.stem.rsplit("_", 1)[0].replace("_", " ")
|
||||
conn.execute("INSERT OR REPLACE INTO card_images VALUES (?, ?)",
|
||||
(card_name, str(img_path)))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
build_index()
|
||||
```
|
||||
|
||||
### Step 3 — Card Enricher (`api/card_enricher.py`)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = Path("data/card_images.db")
|
||||
STATIC_URL = "http://localhost:8000/static/card-images"
|
||||
|
||||
def enrich_card(card: dict) -> dict:
|
||||
"""Inject local art_url into every CardDTO before sending to Unity."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.execute("SELECT path FROM card_images WHERE name = ?", (card["name"],))
|
||||
row = cursor.fetchone()
|
||||
conn.close()
|
||||
if row:
|
||||
relative = Path(row[0]).relative_to("static/card-images")
|
||||
card["art_url"] = f"{STATIC_URL}/{relative}"
|
||||
else:
|
||||
card["art_url"] = f"{STATIC_URL}/card_back.jpg"
|
||||
return card
|
||||
```
|
||||
|
||||
### Step 4 — FastAPI Static Mount (`main.py` addition)
|
||||
|
||||
```python
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
app.mount("/static/card-images", StaticFiles(directory="static/card-images"), name="card-images")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Forge Bridge (Rules Engine)
|
||||
|
||||
### ForgeSubprocessManager (`forge/subprocess_manager.py`)
|
||||
|
||||
```python
|
||||
import subprocess, json, asyncio, threading
|
||||
from pathlib import Path
|
||||
|
||||
FORGE_JAR = Path("forge/forge-game.jar")
|
||||
|
||||
class ForgeSubprocessManager:
|
||||
def __init__(self):
|
||||
self.process = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def start(self, deck_paths: list[str], format: str = "commander"):
|
||||
cmd = [
|
||||
"java", "-jar", str(FORGE_JAR),
|
||||
"--headless",
|
||||
"--format", format,
|
||||
"--decks", *deck_paths,
|
||||
"--output", "json"
|
||||
]
|
||||
self.process = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
bufsize=1
|
||||
)
|
||||
|
||||
def read_state(self) -> dict:
|
||||
with self._lock:
|
||||
line = self.process.stdout.readline()
|
||||
return json.loads(line)
|
||||
|
||||
def send_action(self, action: dict):
|
||||
with self._lock:
|
||||
self.process.stdin.write(json.dumps(action) + "\n")
|
||||
self.process.stdin.flush()
|
||||
|
||||
def stop(self):
|
||||
if self.process:
|
||||
self.process.terminate()
|
||||
```
|
||||
|
||||
### Data Transfer Objects (`api/dtos.py`)
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
|
||||
class CardDTO(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
type: str
|
||||
power: Optional[str]
|
||||
toughness: Optional[str]
|
||||
mana_cost: str
|
||||
art_url: str = ""
|
||||
tapped: bool = False
|
||||
zone: str = "hand"
|
||||
|
||||
class PlayerDTO(BaseModel):
|
||||
id: str
|
||||
life: int
|
||||
commander_damage: dict[str, int]
|
||||
commander_tax: int
|
||||
hand: list[CardDTO]
|
||||
battlefield: list[CardDTO]
|
||||
graveyard: list[CardDTO]
|
||||
exile: list[CardDTO]
|
||||
command_zone: list[CardDTO]
|
||||
library_count: int
|
||||
mana_pool: dict[str, int]
|
||||
is_ai: bool
|
||||
personality: Optional[str]
|
||||
|
||||
class GameStateDTO(BaseModel):
|
||||
game_id: str
|
||||
phase: str
|
||||
active_player: str
|
||||
priority: str
|
||||
turn_number: int
|
||||
players: list[PlayerDTO]
|
||||
stack: list[dict]
|
||||
legal_actions: list[dict]
|
||||
winner: Optional[str]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — FastAPI WebSocket Game Server
|
||||
|
||||
### Game Loop (`api/game_loop.py`)
|
||||
|
||||
```python
|
||||
import asyncio, uuid
|
||||
from .dtos import GameStateDTO
|
||||
from forge.subprocess_manager import ForgeSubprocessManager
|
||||
from ai.ai_orchestrator import get_ai_action
|
||||
from ai.personality import assign_personalities
|
||||
from api.card_enricher import enrich_card
|
||||
|
||||
active_games: dict[str, dict] = {}
|
||||
|
||||
async def start_game(deck_paths: list[str], ai_players: list[str]) -> str:
|
||||
game_id = str(uuid.uuid4())
|
||||
forge = ForgeSubprocessManager()
|
||||
forge.start(deck_paths)
|
||||
|
||||
# Assign AI personalities at game creation
|
||||
archetypes = detect_archetypes(deck_paths, ai_players) # ChromaDB query
|
||||
personalities = assign_personalities(ai_players, archetypes)
|
||||
|
||||
active_games[game_id] = {
|
||||
"forge": forge,
|
||||
"ai_players": ai_players,
|
||||
"personalities": personalities,
|
||||
"websockets": []
|
||||
}
|
||||
asyncio.create_task(game_loop(game_id))
|
||||
return game_id
|
||||
|
||||
async def game_loop(game_id: str):
|
||||
game = active_games[game_id]
|
||||
forge = game["forge"]
|
||||
|
||||
while True:
|
||||
raw_state = forge.read_state()
|
||||
if raw_state.get("winner"):
|
||||
await broadcast(game_id, raw_state)
|
||||
break
|
||||
|
||||
# Enrich every card with local art_url
|
||||
state = enrich_state(raw_state)
|
||||
await broadcast(game_id, state)
|
||||
|
||||
priority_player = state["priority"]
|
||||
if priority_player in game["ai_players"]:
|
||||
personality = game["personalities"][priority_player]
|
||||
action = await get_ai_action(state, priority_player, personality)
|
||||
forge.send_action(action)
|
||||
# else: wait for Unity client to send action via WebSocket
|
||||
|
||||
async def broadcast(game_id: str, state: dict):
|
||||
game = active_games[game_id]
|
||||
for ws in game["websockets"]:
|
||||
await ws.send_json(state)
|
||||
|
||||
def enrich_state(state: dict) -> dict:
|
||||
for player in state["players"]:
|
||||
for zone in ["hand", "battlefield", "graveyard", "exile", "command_zone"]:
|
||||
player[zone] = [enrich_card(c) for c in player.get(zone, [])]
|
||||
return state
|
||||
```
|
||||
|
||||
### WebSocket Endpoint (`api/routes/game.py`)
|
||||
|
||||
```python
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
from api.game_loop import active_games, start_game
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@router.websocket("/ws/game/{game_id}")
|
||||
async def game_ws(websocket: WebSocket, game_id: str):
|
||||
await websocket.accept()
|
||||
game = active_games.get(game_id)
|
||||
if not game:
|
||||
await websocket.close(code=4004)
|
||||
return
|
||||
game["websockets"].append(websocket)
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
# Human player action forwarded directly to Forge
|
||||
game["forge"].send_action(data)
|
||||
except WebSocketDisconnect:
|
||||
game["websockets"].remove(websocket)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Unity TCGEngine Board (2D)
|
||||
|
||||
### Project Layout
|
||||
|
||||
```
|
||||
unity-commander-ui/
|
||||
├── Assets/
|
||||
│ ├── TCGEngine/ ← TCGEngine base (unmodified)
|
||||
│ ├── Commander/
|
||||
│ │ ├── Scripts/
|
||||
│ │ │ ├── GameNetworkManager.cs ← WebSocket ↔ FastAPI
|
||||
│ │ │ ├── CommanderBoardUI.cs ← 4-player layout controller
|
||||
│ │ │ ├── PlayerAreaUI.cs ← per-player zone panel
|
||||
│ │ │ ├── CardImageLoader.cs ← local cache texture fetcher
|
||||
│ │ │ ├── ZonePanel.cs ← Hand/Library/Graveyard/Exile/Command
|
||||
│ │ │ └── AIThinkingIndicator.cs ← spinner while AI brain is deciding
|
||||
│ │ └── Prefabs/
|
||||
│ │ ├── CardPrefab.prefab
|
||||
│ │ ├── PlayerAreaPrefab.prefab
|
||||
│ │ └── ZonePanelPrefab.prefab
|
||||
│ └── Scenes/
|
||||
│ └── CommanderBoard.unity
|
||||
```
|
||||
|
||||
### Board Layout (matches screenshot)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 3) │
|
||||
│ [COMMAND] │
|
||||
├──────────────────────────────┬───────────────────────────────────┤
|
||||
│ │ │
|
||||
│ │ │
|
||||
│ BATTLEFIELD │ BATTLEFIELD │
|
||||
│ (Player 1) │ (Player 2) │
|
||||
│ BATTLEFIELD (center) │
|
||||
│ BATTLEFIELD │ BATTLEFIELD │
|
||||
│ (Player 4) │ (Player 3) │
|
||||
│ │ │
|
||||
├──────────────────────────────┴───────────────────────────────────┤
|
||||
│ [COMMAND] │
|
||||
│ [LIBRARY][GRAVEYARD][EXILE] [────── HAND ──────] (Player 1) │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### GameNetworkManager.cs
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using NativeWebSocket;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
public class GameNetworkManager : MonoBehaviour
|
||||
{
|
||||
public static GameNetworkManager Instance { get; private set; }
|
||||
|
||||
[SerializeField] private string serverUrl = "ws://localhost:8000/ws/game/";
|
||||
[SerializeField] private string gameId;
|
||||
|
||||
private WebSocket _socket;
|
||||
|
||||
public delegate void GameStateReceived(GameStateDTO state);
|
||||
public event GameStateReceived OnGameStateReceived;
|
||||
|
||||
async void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
_socket = new WebSocket($"{serverUrl}{gameId}");
|
||||
|
||||
_socket.OnMessage += (bytes) =>
|
||||
{
|
||||
var json = System.Text.Encoding.UTF8.GetString(bytes);
|
||||
var state = JsonConvert.DeserializeObject<GameStateDTO>(json);
|
||||
UnityMainThreadDispatcher.Enqueue(() => OnGameStateReceived?.Invoke(state));
|
||||
};
|
||||
|
||||
await _socket.Connect();
|
||||
}
|
||||
|
||||
public async void SendAction(object action)
|
||||
{
|
||||
var json = JsonConvert.SerializeObject(action);
|
||||
await _socket.SendText(json);
|
||||
}
|
||||
|
||||
void Update() => _socket?.DispatchMessageQueue();
|
||||
|
||||
async void OnDestroy() => await _socket?.Close();
|
||||
}
|
||||
```
|
||||
|
||||
### CardImageLoader.cs
|
||||
|
||||
```csharp
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
using UnityEngine.UI;
|
||||
|
||||
public class CardImageLoader : MonoBehaviour
|
||||
{
|
||||
private static Dictionary<string, Texture2D> _cache = new();
|
||||
|
||||
public static IEnumerator LoadCardArt(string artUrl, RawImage target)
|
||||
{
|
||||
if (_cache.TryGetValue(artUrl, out var cached))
|
||||
{
|
||||
target.texture = cached;
|
||||
yield break;
|
||||
}
|
||||
|
||||
using var req = UnityWebRequestTexture.GetTexture(artUrl);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result == UnityWebRequest.Result.Success)
|
||||
{
|
||||
var tex = DownloadHandlerTexture.GetContent(req);
|
||||
_cache[artUrl] = tex;
|
||||
target.texture = tex;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogWarning($"Failed to load card art: {artUrl}");
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### AIThinkingIndicator.cs
|
||||
|
||||
```csharp
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// Shows a spinner + brain label while the AI is deciding.
|
||||
public class AIThinkingIndicator : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private GameObject spinnerPanel;
|
||||
[SerializeField] private Text brainLabel;
|
||||
[SerializeField] private Text personalityLabel;
|
||||
|
||||
public void Show(string brain, string personality)
|
||||
{
|
||||
spinnerPanel.SetActive(true);
|
||||
brainLabel.text = $"🧠 {brain}";
|
||||
personalityLabel.text = $"🎭 {personality}";
|
||||
}
|
||||
|
||||
public void Hide() => spinnerPanel.SetActive(false);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — Commander Zone UIs
|
||||
|
||||
### Zone Definitions per Player
|
||||
|
||||
| Zone Panel | Content | Notes |
|
||||
|---|---|---|
|
||||
| `LIBRARY` | Count only | Click → draw/look at top |
|
||||
| `GRAVEYARD` | Scrollable card list | Full card art on hover |
|
||||
| `EXILE` | Scrollable card list | Includes face-down indicators |
|
||||
| `HAND` | Wide panel, card art | Hidden to opponents |
|
||||
| `COMMAND` | Commander card(s) | Shows commander tax counter |
|
||||
| `BATTLEFIELD` | Open grid | Tapped/untapped state, counters |
|
||||
|
||||
### Commander-Specific UI Elements
|
||||
|
||||
```csharp
|
||||
public class CommanderZoneUI : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Text taxCounter; // "Tax: +{2N}"
|
||||
[SerializeField] private RawImage commanderArt;
|
||||
[SerializeField] private Text commanderDamageDealt; // per opponent
|
||||
|
||||
public void Refresh(PlayerDTO player)
|
||||
{
|
||||
int tax = player.CommanderTax;
|
||||
taxCounter.text = tax > 0 ? $"+{tax * 2}" : "";
|
||||
|
||||
if (player.CommandZone.Count > 0)
|
||||
StartCoroutine(CardImageLoader.LoadCardArt(
|
||||
player.CommandZone[0].ArtUrl, commanderArt));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Commander Rules Validation Layer
|
||||
|
||||
Forge handles all rules natively. FastAPI validates the AI response is within the legal actions list Forge returns — no custom rules code needed in Python or Unity.
|
||||
|
||||
Key Commander rules confirmed handled by Forge headless:
|
||||
- **Commander Tax** — +2 generic per previous cast from command zone
|
||||
- **21 Commander Damage** — tracked per attacker/defender pair
|
||||
- **40 Starting Life**
|
||||
- **Zone Redirect** — commander goes to command zone instead of graveyard/exile (player choice)
|
||||
- **Color Identity** — deck validation at game start
|
||||
- **Singleton** — deck validation at game start
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — AI Decision Logging & PPO Pipeline
|
||||
|
||||
Every AI decision written to `logs/ai_decisions.jsonl`:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": 1743520800.123,
|
||||
"player_id": "player_2",
|
||||
"brain": "deepseek-r1:14b",
|
||||
"personality": "aggro_beatdown",
|
||||
"phase": "declare_attackers",
|
||||
"action": {"action": "attack", "card_id": "c_042", "targets": ["player_1"], "reasoning": "Open attack, no blockers visible"},
|
||||
"latency_ms": 847
|
||||
}
|
||||
```
|
||||
|
||||
This log becomes the **supervised learning dataset** for the next PPO training iteration. Each (state, action) pair from winning games is a positive training example.
|
||||
|
||||
---
|
||||
|
||||
## Project Directory Structure
|
||||
|
||||
```
|
||||
commander-ai-lab/
|
||||
├── main.py ← FastAPI app, mounts static files
|
||||
├── api/
|
||||
│ ├── dtos.py ← Pydantic DTOs
|
||||
│ ├── card_enricher.py ← Injects art_url into CardDTOs
|
||||
│ ├── game_loop.py ← Main game orchestration loop
|
||||
│ └── routes/
|
||||
│ └── game.py ← WebSocket + REST endpoints
|
||||
├── ai/
|
||||
│ ├── ai_orchestrator.py ← Main AI decision entry point
|
||||
│ ├── brain_selector.py ← DeepSeek-R1 vs GPT-OSS routing
|
||||
│ ├── prompt_builder.py ← Game state + RAG → prompt
|
||||
│ └── personality.py ← 8 personalities + assignment
|
||||
├── forge/
|
||||
│ └── subprocess_manager.py ← Forge JVM stdin/stdout bridge
|
||||
├── scripts/
|
||||
│ ├── download_card_images.py ← One-time Scryfall bulk download
|
||||
│ └── build_image_index.py ← SQLite name→path index
|
||||
├── static/
|
||||
│ └── card-images/ ← ~2GB local image cache
|
||||
│ └── {set_code}/
|
||||
│ └── {CardName}_{num}.jpg
|
||||
├── data/
|
||||
│ └── card_images.db ← SQLite image index
|
||||
├── logs/
|
||||
│ └── ai_decisions.jsonl ← PPO training dataset
|
||||
├── unity-commander-ui/ ← Unity 2D TCGEngine project
|
||||
└── docs/
|
||||
└── implementation_plan.md ← This document
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phased Timeline
|
||||
|
||||
| Phase | Scope | Est. Time |
|
||||
|---|---|---|
|
||||
| **0** | Scryfall image download + SQLite index + FastAPI static mount | 1 day |
|
||||
| **1** | Forge subprocess bridge + DTO definitions | 2–3 days |
|
||||
| **2** | AI Orchestrator + brain selector + prompt builder + personality system | 3–4 days |
|
||||
| **3** | Unity TCGEngine project setup + WebSocket GameNetworkManager | 3–4 days |
|
||||
| **4** | PlayerAreaUI + ZonePanels + CardImageLoader + board layout | 3–4 days |
|
||||
| **5** | Commander zone UI (tax counter, commander damage, zone redirect) | 1–2 days |
|
||||
| **6** | AI decision logging + end-to-end game loop test | 1–2 days |
|
||||
|
||||
**Total estimated: ~3 weeks to first playable Commander game with live AI opponents.**
|
||||
|
||||
---
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|---|---|
|
||||
| Forge = rules authority | Forge already handles all Commander rules natively. No custom rules code. |
|
||||
| TCGEngine = dumb renderer | All game logic lives in Forge. Unity only renders state it receives. |
|
||||
| FastAPI = hub | Bridges Forge ↔ Unity ↔ AI. Single point of truth. |
|
||||
| Dual-brain routing | DeepSeek-R1 excels at analytical/math decisions; GPT-OSS at strategic sequencing. |
|
||||
| ChromaDB scoped per player | RAG context relevant to the active player's deck, not global card knowledge. |
|
||||
| Personality persists per game | Consistent playstyle per AI across all 4 players per session. |
|
||||
| Local image cache | Eliminates network latency for card art. ~2GB one-time download. |
|
||||
| ai_decisions.jsonl | Every AI decision is a future PPO training sample. |
|
||||
+634
@@ -0,0 +1,634 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MTG Commander Battlefield</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260403_0300">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ====================================================
|
||||
SETUP SCREEN
|
||||
==================================================== -->
|
||||
<div id="setup-screen">
|
||||
<!-- Populated by ui.js initSetupScreen() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TABLE SURFACE — 4-Quadrant Battlefield
|
||||
==================================================== -->
|
||||
<div class="table-surface" id="game-battlefield" style="display:none;">
|
||||
|
||||
<!-- TOP BAR: Phase tracker + controls -->
|
||||
<div id="top-bar">
|
||||
<span class="top-bar-logo">Commander</span>
|
||||
|
||||
<div class="turn-counter-badge" id="turn-counter">Turn <span>1</span></div>
|
||||
|
||||
<div class="active-player-indicator" id="active-player-indicator">
|
||||
<span class="active-player-dot" id="active-dot" style="background:#c0392b;"></span>
|
||||
Player 1's Turn
|
||||
</div>
|
||||
|
||||
<!-- Phase track -->
|
||||
<div class="phase-track-wrapper">
|
||||
<div class="phase-track" id="phase-track"></div>
|
||||
</div>
|
||||
|
||||
<!-- Phase/turn action buttons -->
|
||||
<div class="top-bar-actions">
|
||||
<button class="btn-phase" onclick="nextPhase()" title="Advance to next phase (N)">Next Phase</button>
|
||||
<button class="btn-phase btn-next-turn" onclick="nextTurn()" title="End turn, advance to next player (T)">Next Turn</button>
|
||||
</div>
|
||||
|
||||
<!-- Utility buttons -->
|
||||
<div class="top-bar-utils">
|
||||
<button class="btn-util" id="btn-undo" onclick="undo()" title="Undo (Ctrl+Z)" disabled>↩</button>
|
||||
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>↪</button>
|
||||
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">❇</button>
|
||||
<button class="btn-util" onclick="openDiceModal()" title="Dice & Coins (G)">🎲</button>
|
||||
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
|
||||
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">⚙</button>
|
||||
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">🔍</button>
|
||||
</div>
|
||||
|
||||
<!-- Storm counter -->
|
||||
<span id="storm-badge" style="display:none;font-size:10px;color:var(--mtg-gold);background:rgba(200,168,67,0.15);border:1px solid rgba(200,168,67,0.3);border-radius:4px;padding:2px 7px;white-space:nowrap;">Storm: 0</span>
|
||||
</div>
|
||||
|
||||
<!-- DIVIDER LINES -->
|
||||
<div class="divider-h"></div>
|
||||
<div class="divider-v"></div>
|
||||
|
||||
<!-- CORNER ACCENTS -->
|
||||
<div class="corner-accent tl"></div>
|
||||
<div class="corner-accent tr"></div>
|
||||
<div class="corner-accent bl"></div>
|
||||
<div class="corner-accent br"></div>
|
||||
|
||||
<!-- CENTER EMBLEM -->
|
||||
<div class="center-emblem">
|
||||
<svg viewBox="0 0 100 100" fill="none">
|
||||
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
|
||||
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
||||
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
|
||||
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- ================================================
|
||||
4-QUADRANT BATTLEFIELD
|
||||
================================================ -->
|
||||
<div class="battlefield">
|
||||
|
||||
<!-- PLAYER 1: Top-Left (Crimson) -->
|
||||
<div class="player-area p1" id="player-area-1">
|
||||
<span class="player-label" id="player-label-1">Player 1</span>
|
||||
<div class="life-counter" id="life-counter-1">
|
||||
<button onclick="adjustLife(0,-1)" aria-label="Decrease P1 life">−</button>
|
||||
<span class="life-counter-value" id="life-0" onclick="showSetLifeDialog(0)">40</span>
|
||||
<button onclick="adjustLife(0,1)" aria-label="Increase P1 life">+</button>
|
||||
</div>
|
||||
<div class="poison-badge" id="poison-0" style="display:none;" onclick="adjustPoison(0,1)" title="Poison counters (click +1, right-click -1)" oncontextmenu="event.preventDefault();adjustPoison(0,-1)">0 poison</div>
|
||||
|
||||
<!-- Zone Row: Library, Exile, Graveyard, Hand -->
|
||||
<div class="zone-row" id="zone-row-1">
|
||||
<div class="zone library" id="lib-zone-1" data-player="1" data-zone="library"
|
||||
onclick="openZoneModal(0,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-1">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-1" data-player="1" data-zone="exile"
|
||||
onclick="openZoneModal(0,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-1" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-1" data-player="1" data-zone="graveyard"
|
||||
onclick="openZoneModal(0,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-1" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-1" data-player="1" data-zone="hand"
|
||||
onclick="openZoneModal(0,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-1">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Commander Row -->
|
||||
<div class="commander-row" id="cmd-row-1">
|
||||
<div class="zone commander" id="cmd-zone-1" data-player="1" data-zone="command"
|
||||
onclick="openZoneModal(0,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Battle Zone -->
|
||||
<div class="battle-zone" id="battle-zone-1">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
<!-- Battlefield cards injected here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 2: Top-Right (Sapphire) -->
|
||||
<div class="player-area p2" id="player-area-2">
|
||||
<span class="player-label" id="player-label-2">Player 2</span>
|
||||
<div class="life-counter" id="life-counter-2">
|
||||
<button onclick="adjustLife(1,-1)" aria-label="Decrease P2 life">−</button>
|
||||
<span class="life-counter-value" id="life-1" onclick="showSetLifeDialog(1)">40</span>
|
||||
<button onclick="adjustLife(1,1)" aria-label="Increase P2 life">+</button>
|
||||
</div>
|
||||
<div class="poison-badge" id="poison-1" style="display:none;" onclick="adjustPoison(1,1)" oncontextmenu="event.preventDefault();adjustPoison(1,-1)">0 poison</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-2">
|
||||
<div class="zone library" id="lib-zone-2" data-player="2" data-zone="library"
|
||||
onclick="openZoneModal(1,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-2">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-2" data-player="2" data-zone="exile"
|
||||
onclick="openZoneModal(1,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-2" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-2" data-player="2" data-zone="graveyard"
|
||||
onclick="openZoneModal(1,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-2" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-2" data-player="2" data-zone="hand"
|
||||
onclick="openZoneModal(1,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-2">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-2">
|
||||
<div class="zone commander" id="cmd-zone-2" data-player="2" data-zone="command"
|
||||
onclick="openZoneModal(1,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="battle-zone" id="battle-zone-2">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 3: Bottom-Left (Emerald) -->
|
||||
<div class="player-area p3" id="player-area-3">
|
||||
<!-- Battle zone on top for bottom players -->
|
||||
<div class="battle-zone" id="battle-zone-3">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-3">
|
||||
<div class="zone commander" id="cmd-zone-3" data-player="3" data-zone="command"
|
||||
onclick="openZoneModal(2,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-3">
|
||||
<div class="zone library" id="lib-zone-3" data-player="3" data-zone="library"
|
||||
onclick="openZoneModal(2,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-3">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-3" data-player="3" data-zone="exile"
|
||||
onclick="openZoneModal(2,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-3" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-3" data-player="3" data-zone="graveyard"
|
||||
onclick="openZoneModal(2,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-3" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-3" data-player="3" data-zone="hand"
|
||||
onclick="openZoneModal(2,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-3">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-3">Player 3</span>
|
||||
<div class="life-counter" id="life-counter-3">
|
||||
<button onclick="adjustLife(2,-1)" aria-label="Decrease P3 life">−</button>
|
||||
<span class="life-counter-value" id="life-2" onclick="showSetLifeDialog(2)">40</span>
|
||||
<button onclick="adjustLife(2,1)" aria-label="Increase P3 life">+</button>
|
||||
</div>
|
||||
<div class="poison-badge" id="poison-2" style="display:none;" onclick="adjustPoison(2,1)" oncontextmenu="event.preventDefault();adjustPoison(2,-1)">0 poison</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 4: Bottom-Right (Gold) -->
|
||||
<div class="player-area p4" id="player-area-4">
|
||||
<div class="battle-zone" id="battle-zone-4">
|
||||
<div class="battle-zone-inner"></div>
|
||||
<span class="battle-zone-label">Battlefield</span>
|
||||
</div>
|
||||
|
||||
<div class="commander-row" id="cmd-row-4">
|
||||
<div class="zone commander" id="cmd-zone-4" data-player="4" data-zone="command"
|
||||
onclick="openZoneModal(3,'command')" data-tooltip="Command Zone">
|
||||
<span class="zone-icon">👑</span>
|
||||
<span class="zone-name">Commander</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zone-row" id="zone-row-4">
|
||||
<div class="zone library" id="lib-zone-4" data-player="4" data-zone="library"
|
||||
onclick="openZoneModal(3,'library')" data-tooltip="Library">
|
||||
<span class="card-count" id="lib-count-4">0</span>
|
||||
<span class="zone-icon">📚</span>
|
||||
<span class="zone-name">Library</span>
|
||||
</div>
|
||||
<div class="zone exile" id="exile-zone-4" data-player="4" data-zone="exile"
|
||||
onclick="openZoneModal(3,'exile')" data-tooltip="Exile">
|
||||
<span class="card-count" id="exile-count-4" style="display:none;">0</span>
|
||||
<span class="zone-icon">⬡</span>
|
||||
<span class="zone-name">Exile</span>
|
||||
</div>
|
||||
<div class="zone graveyard" id="gv-zone-4" data-player="4" data-zone="graveyard"
|
||||
onclick="openZoneModal(3,'graveyard')" data-tooltip="Graveyard">
|
||||
<span class="card-count" id="gv-count-4" style="display:none;">0</span>
|
||||
<span class="zone-icon">☠</span>
|
||||
<span class="zone-name">Grave</span>
|
||||
</div>
|
||||
<div class="zone hand" id="hand-zone-4" data-player="4" data-zone="hand"
|
||||
onclick="openZoneModal(3,'hand')" data-tooltip="Hand">
|
||||
<span class="zone-icon">🃏</span>
|
||||
<span class="zone-name">Hand (<span id="hand-count-4">0</span>)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="player-label" id="player-label-4">Player 4</span>
|
||||
<div class="life-counter" id="life-counter-4">
|
||||
<button onclick="adjustLife(3,-1)" aria-label="Decrease P4 life">−</button>
|
||||
<span class="life-counter-value" id="life-3" onclick="showSetLifeDialog(3)">40</span>
|
||||
<button onclick="adjustLife(3,1)" aria-label="Increase P4 life">+</button>
|
||||
</div>
|
||||
<div class="poison-badge" id="poison-3" style="display:none;" onclick="adjustPoison(3,1)" oncontextmenu="event.preventDefault();adjustPoison(3,-1)">0 poison</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /.battlefield -->
|
||||
|
||||
<!-- COMBAT SVG OVERLAY (arrows) -->
|
||||
<svg id="combat-overlay" xmlns="http://www.w3.org/2000/svg" style="position:absolute;inset:44px 0 0 0;pointer-events:none;z-index:30;">
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(211,32,42,0.7)"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<g id="combat-arrows-svg"></g>
|
||||
</svg>
|
||||
|
||||
<!-- MANA POOL DISPLAY (floating, bottom center) -->
|
||||
<div id="mana-pool-display" style="display:none;">
|
||||
<span class="mana-pool-label">MANA:</span>
|
||||
<span class="mana-empty">Empty</span>
|
||||
</div>
|
||||
|
||||
<!-- FLOATING LOG -->
|
||||
<div id="floating-log" class="floating-log"></div>
|
||||
|
||||
<!-- SHORTCUT HINT -->
|
||||
<div class="shortcut-hint"></div>
|
||||
|
||||
<!-- CARD SEARCH SIDEBAR (right side, collapsible) -->
|
||||
<div id="card-search-sidebar" class="collapsed">
|
||||
<div class="sidebar-header">
|
||||
<span class="sidebar-title">Card Search</span>
|
||||
<button class="sidebar-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Close search">✕</button>
|
||||
</div>
|
||||
<div class="search-input-wrap">
|
||||
<input class="search-input" id="card-search-input" type="text" placeholder="Search Scryfall..." autocomplete="off"/>
|
||||
</div>
|
||||
<div class="search-status" id="search-status">Type to search cards...</div>
|
||||
<div class="card-results" id="card-results"></div>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar edge tab -->
|
||||
<div id="sidebar-edge-tab" class="sidebar-edge-tab" title="Toggle card search">‹</div>
|
||||
|
||||
</div><!-- /.table-surface / #game-battlefield -->
|
||||
|
||||
<!-- ====================================================
|
||||
CONTEXT MENU
|
||||
==================================================== -->
|
||||
<div id="context-menu"></div>
|
||||
|
||||
<!-- ====================================================
|
||||
FLOATING CARD PREVIEW
|
||||
==================================================== -->
|
||||
<div id="card-preview">
|
||||
<img src="" alt="Card Preview"/>
|
||||
<div class="card-preview-pt-overlay" id="card-preview-pt-overlay"></div>
|
||||
<div class="card-preview-info">
|
||||
<div class="card-preview-name"></div>
|
||||
<div class="card-preview-type"></div>
|
||||
<div class="card-preview-oracle"></div>
|
||||
<div class="card-preview-pt"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
ZONE MODAL
|
||||
==================================================== -->
|
||||
<div id="zone-modal">
|
||||
<div class="zone-modal-card">
|
||||
<div class="zone-modal-header">
|
||||
<span class="zone-modal-title" id="zone-modal-title">Player Zones</span>
|
||||
<button class="btn-close" onclick="closeZoneModal()">✕</button>
|
||||
</div>
|
||||
<div class="zone-modal-tabs" id="zone-modal-tabs">
|
||||
<!-- Populated by renderZoneModal() -->
|
||||
</div>
|
||||
<div class="zone-modal-body" id="zone-modal-body">
|
||||
<!-- Populated by renderZoneModal() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
DICE MODAL
|
||||
==================================================== -->
|
||||
<div id="dice-modal">
|
||||
<div class="dice-modal-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
|
||||
<span style="font-family:var(--font-display);font-size:14px;font-weight:700;color:var(--color-text-muted);text-transform:uppercase;letter-spacing:0.08em;">Dice & Coins</span>
|
||||
<button class="btn-close" onclick="closeDiceModal()">✕</button>
|
||||
</div>
|
||||
<div class="dice-result-display" id="dice-result-display">—</div>
|
||||
<div class="dice-label" id="dice-label">Choose a die</div>
|
||||
<div class="dice-buttons">
|
||||
<button class="btn-dice" onclick="rollDice(4)">d4</button>
|
||||
<button class="btn-dice" onclick="rollDice(6)">d6</button>
|
||||
<button class="btn-dice" onclick="rollDice(8)">d8</button>
|
||||
<button class="btn-dice" onclick="rollDice(10)">d10</button>
|
||||
<button class="btn-dice" onclick="rollDice(12)">d12</button>
|
||||
<button class="btn-dice" onclick="rollDice(20)">d20</button>
|
||||
<button class="btn-dice" onclick="rollDice(100)">d100</button>
|
||||
<button class="btn-dice coin" onclick="flipCoin()">Coin Flip</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TOKEN MODAL
|
||||
==================================================== -->
|
||||
<div id="token-modal">
|
||||
<div class="token-modal-card">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Create Token</span>
|
||||
<button class="btn-close" onclick="closeTokenModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="token-modal-body">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px;">
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Token Name</label>
|
||||
<input class="game-input" id="token-name" type="text" placeholder="e.g. 1/1 White Soldier"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Type Line</label>
|
||||
<input class="game-input" id="token-type" type="text" placeholder="Creature — Soldier"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Power/Toughness</label>
|
||||
<input class="game-input" id="token-pt" type="text" placeholder="1/1"/>
|
||||
</div>
|
||||
<div>
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Count</label>
|
||||
<input class="game-input" id="token-count" type="number" value="1" min="1" max="20"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-bottom:10px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Keywords (comma-separated)</label>
|
||||
<input class="game-input" id="token-keywords" type="text" placeholder="Flying, Vigilance"/>
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="font-size:11px;font-weight:600;color:var(--color-text);display:block;margin-bottom:4px;">Player</label>
|
||||
<select class="game-input" id="token-player-select" style="height:28px;">
|
||||
<option value="0">Player 1</option>
|
||||
<option value="1">Player 2</option>
|
||||
<option value="2">Player 3</option>
|
||||
<option value="3">Player 4</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<button class="btn btn-primary" onclick="createTokenFromModal()">Create Token(s)</button>
|
||||
<button class="btn btn-cancel" onclick="closeTokenModal()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
DECK MODAL
|
||||
==================================================== -->
|
||||
<div id="deck-modal">
|
||||
<div class="deck-modal-card">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Deck Manager</span>
|
||||
<button class="btn-close" onclick="closeDeckModal()">✕</button>
|
||||
</div>
|
||||
<div class="modal-body" id="deck-modal-body">
|
||||
<!-- Populated by renderDeckModalContent() -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
COMMANDER DAMAGE MATRIX MODAL
|
||||
==================================================== -->
|
||||
<div id="cmd-dmg-matrix-modal">
|
||||
<!-- Populated by renderCmdDmgMatrix() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
TRIGGER STACK PANEL
|
||||
==================================================== -->
|
||||
<div id="trigger-stack-panel">
|
||||
<div class="trigger-stack-card">
|
||||
<div class="trigger-stack-header">
|
||||
<span class="trigger-stack-title">Trigger Stack</span>
|
||||
<button class="btn-close" style="font-size:14px;width:20px;height:20px;" onclick="closeTriggerStack()">✕</button>
|
||||
</div>
|
||||
<div class="trigger-stack-body" id="trigger-stack-body">
|
||||
<div class="trigger-stack-empty">No pending triggers.</div>
|
||||
</div>
|
||||
<div class="trigger-stack-actions">
|
||||
<button class="btn-resolve" onclick="resolveNextTrigger()">Resolve Next</button>
|
||||
<button class="btn-resolve-all" onclick="resolveAllTriggers()">Resolve All</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
SETTINGS PANEL
|
||||
==================================================== -->
|
||||
<div id="settings-panel">
|
||||
<div class="modal-header" style="flex-shrink:0;">
|
||||
<span class="modal-title">Settings</span>
|
||||
<button class="btn-close" onclick="toggleSettingsPanel()">✕</button>
|
||||
</div>
|
||||
<div class="settings-body" id="settings-body" style="flex:1;overflow-y:auto;padding:12px;">
|
||||
<!-- Populated by renderSettingsPanel() -->
|
||||
</div>
|
||||
<div style="padding:10px 14px;border-top:1px solid var(--color-border);display:flex;gap:8px;flex-wrap:wrap;flex-shrink:0;">
|
||||
<button class="btn btn-primary" onclick="openDiceModal()">🎲 Dice</button>
|
||||
<button class="btn btn-gold" onclick="openCmdDmgMatrix()">CMD Damage Matrix</button>
|
||||
<button class="btn btn-cancel" onclick="checkStateBasedActions()">Check SBA</button>
|
||||
<button class="btn btn-cancel" onclick="clearAllManaPools()">Clear All Mana</button>
|
||||
<button class="btn btn-cancel" onclick="autoArrangeBattlefield()">Auto-Arrange</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
PRIORITY BANNER
|
||||
==================================================== -->
|
||||
<div id="priority-banner">
|
||||
<span class="priority-text" id="priority-text">Waiting for priority...</span>
|
||||
<button class="btn-priority-pass" onclick="passPriority()">Pass Priority</button>
|
||||
<button class="btn-close" style="font-size:13px;" onclick="closePriorityWindow()">✕</button>
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
LEGAL ACTIONS BAR
|
||||
==================================================== -->
|
||||
<div id="legal-actions-bar">
|
||||
<!-- Populated by renderLegalActionsBar() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
UNDO HISTORY PANEL
|
||||
==================================================== -->
|
||||
<div id="undo-history-panel">
|
||||
<!-- Populated by renderUndoHistory() -->
|
||||
</div>
|
||||
|
||||
<!-- ====================================================
|
||||
MANA COLOR PICKER (dynamically appended) -->
|
||||
<!-- Zone card context menu (dynamically appended) -->
|
||||
<!-- SBA alert panel (dynamically appended) -->
|
||||
<!-- Set-life dialog (dynamically appended) -->
|
||||
|
||||
<!-- ====================================================
|
||||
SCRIPTS
|
||||
==================================================== -->
|
||||
<script src="precons.js?v=20260403_0300"></script>
|
||||
<script src="engine.js?v=20260403_0300"></script>
|
||||
<script src="ui.js?v=20260403_0300"></script>
|
||||
|
||||
<script>
|
||||
// ============================================================
|
||||
// STARTUP & BRIDGE FUNCTIONS
|
||||
// ============================================================
|
||||
|
||||
// Token creation from modal
|
||||
function createTokenFromModal() {
|
||||
if (!gameState) return;
|
||||
const name = document.getElementById('token-name').value.trim() || '1/1 Token';
|
||||
const typeLine = document.getElementById('token-type').value.trim() || 'Creature';
|
||||
const pt = document.getElementById('token-pt').value.trim() || '1/1';
|
||||
const count = parseInt(document.getElementById('token-count').value) || 1;
|
||||
const keywords = document.getElementById('token-keywords').value.trim();
|
||||
const playerIdx = parseInt(document.getElementById('token-player-select').value) || 0;
|
||||
|
||||
const parts = pt.split('/');
|
||||
const power = parts[0] ? parts[0].trim() : '1';
|
||||
const toughness = parts[1] ? parts[1].trim() : '1';
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
addCardToBattlefield(name, '', typeLine, playerIdx, undefined, undefined,
|
||||
keywords ? keywords.replace(/,/g, ' ') : '',
|
||||
{ power, toughness, pt, isToken: true });
|
||||
}
|
||||
addLogEntry('Created ' + count + 'x <strong>' + name + '</strong> token(s) for Player ' + (playerIdx + 1));
|
||||
closeTokenModal();
|
||||
}
|
||||
|
||||
// Pass priority
|
||||
function passPriority() {
|
||||
addLogEntry('Priority passed.');
|
||||
closePriorityWindow();
|
||||
}
|
||||
|
||||
// Open precon browser (stub - opens deck modal for current player)
|
||||
function openPreconBrowser() {
|
||||
if (gameState) openDeckModal(gameState.currentPlayerIndex);
|
||||
}
|
||||
|
||||
// Open collection modal (stub)
|
||||
function openCollectionModal() { showToast('Collection tracker available in full version.'); }
|
||||
|
||||
// Scry dialog stub
|
||||
function showScryDialog(playerId) {
|
||||
if (!gameState) return;
|
||||
const player = gameState.players[playerId];
|
||||
if (!player || player.zones.library.length === 0) {
|
||||
addLogEntry('!! ' + (player ? player.name : 'Player') + ' has no cards to scry.');
|
||||
return;
|
||||
}
|
||||
const count = Math.min(parseInt(prompt('Scry how many? (1-7)', '1') || '1'), 7, player.zones.library.length);
|
||||
if (isNaN(count) || count < 1) return;
|
||||
const topCards = player.zones.library.slice(0, count);
|
||||
const names = topCards.map(c => c.name || '?').join(', ');
|
||||
addLogEntry('<strong>' + player.name + '</strong> scrys ' + count + ': [' + names + ']');
|
||||
}
|
||||
|
||||
// Mill dialog stub
|
||||
function showMillDialog(playerId) {
|
||||
if (!gameState) return;
|
||||
const count = parseInt(prompt('Mill how many cards?', '1') || '1');
|
||||
if (isNaN(count) || count < 1) return;
|
||||
millCards(playerId, count);
|
||||
}
|
||||
|
||||
// Become monarch
|
||||
function becomeMonarch(playerId) {
|
||||
if (!gameState) return;
|
||||
gameState.monarchPlayerIndex = playerId;
|
||||
const player = gameState.players[playerId];
|
||||
addLogEntry('👑 <strong>' + (player ? player.name : 'Player') + '</strong> becomes the Monarch!');
|
||||
updateMonarchBadges();
|
||||
}
|
||||
|
||||
// Empower the life counter right-click on the quadrant labels
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Wire right-click on life values to set-life dialog
|
||||
for (var i = 0; i < 4; i++) {
|
||||
(function(idx) {
|
||||
var lifeEl = document.getElementById('life-' + idx);
|
||||
if (lifeEl) {
|
||||
lifeEl.addEventListener('contextmenu', function(e) {
|
||||
e.preventDefault();
|
||||
showSetLifeDialog(idx);
|
||||
});
|
||||
}
|
||||
})(i);
|
||||
}
|
||||
|
||||
// Life counter right-click on quadrant buttons too
|
||||
// (already handled via inline onclick in HTML)
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+17
-22
@@ -43,8 +43,6 @@ from routes.coach import router as coach_router, init_coach_service
|
||||
from routes.ml import router as ml_router
|
||||
from routes.ws_game import router as ws_game_router
|
||||
from routes.game import router as game_router
|
||||
# Phase 0: card art endpoints
|
||||
from routes.card_art import router as card_art_router
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.api")
|
||||
|
||||
@@ -53,7 +51,7 @@ _allowed = os.environ.get("ALLOWED_ORIGINS", _DEFAULT_ORIGINS)
|
||||
allowed_origins = [o.strip() for o in _allowed.split(",") if o.strip()]
|
||||
|
||||
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ──────────────────────────────────────────────────
|
||||
# ── Lifespan (replaces deprecated @app.on_event) ────────────────────────────────────────────────────────
|
||||
@asynccontextmanager
|
||||
async def _lifespan(application: FastAPI):
|
||||
# ──── STARTUP ────
|
||||
@@ -138,8 +136,7 @@ app.include_router(deckgen_router)
|
||||
app.include_router(coach_router)
|
||||
app.include_router(ws_game_router)
|
||||
app.include_router(ml_router)
|
||||
app.include_router(game_router) # live game session routes
|
||||
app.include_router(card_art_router) # Phase 0: /card-art/* + /api/card-image-*
|
||||
app.include_router(game_router) # live game session routes
|
||||
|
||||
|
||||
# ── API endpoints ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -152,21 +149,8 @@ async def health_check():
|
||||
"""Health check endpoint for load balancers and Unity client polling."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ── Static mounts (MUST come after all @app.get / router includes) ────────────────────────
|
||||
# ── Static UI (MUST come after all @app.get routes) ──────────────────────────────────────────
|
||||
from pathlib import Path as _Path
|
||||
|
||||
# Phase 0: Serve local card images at /static/card-images/
|
||||
# MUST be mounted before the legacy UI wildcard to avoid being shadowed.
|
||||
_card_images_dir = _Path(__file__).parent / "static" / "card-images"
|
||||
_card_images_dir.mkdir(parents=True, exist_ok=True)
|
||||
app.mount(
|
||||
"/static/card-images",
|
||||
StaticFiles(directory=str(_card_images_dir)),
|
||||
name="card-images"
|
||||
)
|
||||
|
||||
# Legacy UI / SPA (wildcard — MUST come last)
|
||||
_legacy_ui_dir = _Path(__file__).parent / "ui"
|
||||
_spa_dir = _Path(__file__).parent / "frontend" / "commander-ai-lab-ui" / "dist"
|
||||
|
||||
@@ -186,14 +170,16 @@ elif _spa_dir.exists():
|
||||
return FileResponse(str(_spa_dir / "index.html"))
|
||||
|
||||
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────
|
||||
# ── Argument parsing + auto-detection helpers ────────────────────────────────────────────────────────
|
||||
def _parse_args():
|
||||
p = argparse.ArgumentParser(description="Commander AI Lab API Server")
|
||||
p.add_argument("--forge-jar", default=os.environ.get("FORGE_JAR", ""))
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR", ""))
|
||||
# Support both FORGE_DIR and FORGE_WORKING_DIR (alias for backwards compat)
|
||||
p.add_argument("--forge-dir", default=os.environ.get("FORGE_DIR") or os.environ.get("FORGE_WORKING_DIR", ""))
|
||||
p.add_argument("--forge-decks-dir", default=os.environ.get("FORGE_DECKS_DIR", ""))
|
||||
p.add_argument("--lab-jar", default=os.environ.get("LAB_JAR", ""))
|
||||
p.add_argument("--precon-dir", default=os.environ.get("PRECON_DIR", ""))
|
||||
p.add_argument("--results-dir", default=os.environ.get("RESULTS_DIR", ""))
|
||||
p.add_argument("--port", type=int, default=int(os.environ.get("LAB_PORT", "8080")))
|
||||
p.add_argument("--ximilar-key", default=os.environ.get("XIMILAR_API_KEY", ""))
|
||||
p.add_argument("--anthropic-key", default=os.environ.get("ANTHROPIC_API_KEY", ""))
|
||||
@@ -268,7 +254,7 @@ def _resolve_forge_jar() -> str:
|
||||
project_root.parent / "forge-repo" / "forge-gui-desktop" / "target",
|
||||
project_root.parent / "forge" / "forge-gui-desktop" / "target",
|
||||
]
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "")
|
||||
forge_dir_env = os.environ.get("FORGE_DIR", "") or os.environ.get("FORGE_WORKING_DIR", "")
|
||||
if forge_dir_env:
|
||||
fd = Path(forge_dir_env)
|
||||
candidates.append(fd.parent / "forge-gui-desktop" / "target")
|
||||
@@ -333,6 +319,15 @@ def main():
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.anthropic_api_key = args.anthropic_key
|
||||
|
||||
# Wire RESULTS_DIR: use explicit env/arg if set, otherwise keep default "results"
|
||||
# but resolve it to an absolute path relative to this file so it works
|
||||
# regardless of what directory the server is launched from.
|
||||
if args.results_dir:
|
||||
CFG.results_dir = args.results_dir
|
||||
else:
|
||||
# Default: results/ next to lab_api.py (always absolute)
|
||||
CFG.results_dir = str(Path(__file__).parent / "results")
|
||||
|
||||
from services.forge_runner import get_java17
|
||||
j17 = get_java17()
|
||||
|
||||
|
||||
+2
-1
@@ -9,7 +9,7 @@ class StartRequest(BaseModel):
|
||||
numGames: int = 100
|
||||
threads: int = 4
|
||||
seed: Optional[int] = None
|
||||
clock: int = 300
|
||||
clock: int = 5000
|
||||
deckSources: Optional[list[Optional[dict]]] = None
|
||||
useLearnedPolicy: bool = False
|
||||
policyStyle: str = "midrange"
|
||||
@@ -170,6 +170,7 @@ class CoachGoalsRequest(BaseModel):
|
||||
meta_focus: Optional[str] = None # aggro, control, combo, midrange, stax
|
||||
budget: Optional[str] = None # budget, medium, no-limit
|
||||
focus_areas: list[str] = [] # e.g., ["ramp", "card draw"]
|
||||
|
||||
class DeckGenV3SubstituteRequest(BaseModel):
|
||||
card_name: str
|
||||
substitute_name: str
|
||||
@@ -0,0 +1,4 @@
|
||||
unzip mtg-commander-lan.zip
|
||||
cd mtg-commander-lan
|
||||
npm install
|
||||
node server.js
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "MTG Commander (HTML)",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ httpx>=0.24.0
|
||||
websockets>=11.0
|
||||
openai>=1.0.0
|
||||
aiosqlite>=0.19.0
|
||||
aiofiles>=23.0.0
|
||||
|
||||
# Anthropic Claude API
|
||||
anthropic>=0.40.0
|
||||
@@ -20,9 +19,6 @@ beautifulsoup4>=4.12.0
|
||||
ollama>=0.2.0
|
||||
scrython>=2.0.0
|
||||
|
||||
# Phase 0: image download script
|
||||
tqdm>=4.65.0
|
||||
|
||||
# Embeddings
|
||||
|
||||
# RAG vector store
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Phase 0 — routes/card_art.py
|
||||
|
||||
FastAPI router for card art endpoints.
|
||||
|
||||
Endpoints:
|
||||
GET /card-art/{scryfall_id}
|
||||
Local-first image redirect.
|
||||
- If local file exists: redirect to /static/card-images/{id}_front.jpg
|
||||
- If not: redirect to Scryfall CDN (no 404, always returns an image)
|
||||
|
||||
GET /card-art/{scryfall_id}/back
|
||||
Same as above but for the back face of DFCs.
|
||||
|
||||
GET /api/card-image-info/{scryfall_id}
|
||||
JSON info: art_url, art_url_back, has_back, is_local
|
||||
|
||||
GET /api/card-image-by-name/{name}
|
||||
Look up by card name (case-insensitive). Returns image info JSON.
|
||||
|
||||
See issue #167
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import urllib.parse
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from commander_ai_lab.cards.image_cache import get_image_cache
|
||||
|
||||
router = APIRouter(tags=["card-art"])
|
||||
|
||||
|
||||
@router.get("/card-art/{scryfall_id}")
|
||||
async def get_card_art(scryfall_id: str):
|
||||
"""
|
||||
Redirect to the front face image for a card.
|
||||
Always returns a redirect — never a 404.
|
||||
Local cache first, Scryfall CDN fallback.
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result:
|
||||
return RedirectResponse(url=result.front_url, status_code=302)
|
||||
# Ultimate fallback: redirect to CDN directly
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
cdn = f"https://cards.scryfall.io/normal/front/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
return RedirectResponse(url=cdn, status_code=302)
|
||||
|
||||
|
||||
@router.get("/card-art/{scryfall_id}/back")
|
||||
async def get_card_art_back(scryfall_id: str):
|
||||
"""
|
||||
Redirect to the back face image for a DFC.
|
||||
Falls back to CDN back URL if not locally cached.
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result and result.back_url:
|
||||
return RedirectResponse(url=result.back_url, status_code=302)
|
||||
d1 = scryfall_id[0] if scryfall_id else "0"
|
||||
d2 = scryfall_id[1] if len(scryfall_id) > 1 else "0"
|
||||
cdn = f"https://cards.scryfall.io/normal/back/{d1}/{d2}/{scryfall_id}.jpg"
|
||||
return RedirectResponse(url=cdn, status_code=302)
|
||||
|
||||
|
||||
@router.get("/api/card-image-info/{scryfall_id}")
|
||||
async def get_card_image_info(scryfall_id: str):
|
||||
"""
|
||||
Return JSON image info for a card by Scryfall ID.
|
||||
Always returns a result (CDN fallback if not cached locally).
|
||||
"""
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_id(scryfall_id)
|
||||
if result:
|
||||
return result.to_dict()
|
||||
raise HTTPException(status_code=404, detail=f"Card not found: {scryfall_id}")
|
||||
|
||||
|
||||
@router.get("/api/card-image-by-name/{name}")
|
||||
async def get_card_image_by_name(name: str):
|
||||
"""
|
||||
Return JSON image info for a card by name (case-insensitive).
|
||||
Uses the SQLite index. Returns 404 if name not found.
|
||||
"""
|
||||
decoded_name = urllib.parse.unquote(name)
|
||||
cache = get_image_cache()
|
||||
result = cache.lookup_by_name(decoded_name)
|
||||
if result:
|
||||
return result.to_dict()
|
||||
raise HTTPException(status_code=404, detail=f"Card name not found: {decoded_name}")
|
||||
+45
-7
@@ -39,6 +39,7 @@ 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 services.draw_game_analyzer import summarize_draw_games
|
||||
from services.logging import log, log_batch
|
||||
|
||||
router = APIRouter(tags=["lab"])
|
||||
@@ -57,6 +58,32 @@ def _sanitize_floats(obj):
|
||||
return obj
|
||||
|
||||
|
||||
def _inject_win_type(data: dict, win_type_override: str) -> None:
|
||||
"""Stamp winType on every per-game dict that doesn't already have one.
|
||||
|
||||
Called by get_result() after loading the JSON from disk.
|
||||
- Uses setdefault so DeepSeek per-game values (already set in forge_runner.py)
|
||||
are never overwritten.
|
||||
- Also stamps metadata.winType for a quick batch-level summary field.
|
||||
- win_type_override comes from state.win_type_override (Java path) or
|
||||
defaults to "Combat" if the attribute is absent (DeepSeek / old batches).
|
||||
"""
|
||||
if not win_type_override or win_type_override == "Combat":
|
||||
# Still stamp Combat as default so the field is always present
|
||||
win_type_override = "Combat"
|
||||
|
||||
# Batch-level metadata field
|
||||
meta = data.get("metadata")
|
||||
if isinstance(meta, dict):
|
||||
meta.setdefault("winType", win_type_override)
|
||||
|
||||
# Per-game dicts nested under data["decks"][N]["games"][M]
|
||||
for deck in data.get("decks", []):
|
||||
for game in deck.get("games", []):
|
||||
if isinstance(game, dict):
|
||||
game.setdefault("winType", win_type_override)
|
||||
|
||||
|
||||
@router.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
@@ -196,6 +223,19 @@ async def get_result(batchId: Optional[str] = None):
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
raise HTTPException(500, f"Failed to read result file: {e}")
|
||||
|
||||
# ── Inject winType into every game dict (fix #175) ──────────────────────
|
||||
win_type_override = getattr(state, "win_type_override", "Combat")
|
||||
_inject_win_type(data, win_type_override)
|
||||
|
||||
# ── Inject draw-game summary (fix #176) ─────────────────────────────────
|
||||
# draw_game_snapshots is populated live by forge_runner._run_process_blocking
|
||||
# from [DRAW] [PARSE-SUMMARY] lines. summarize_draw_games() aggregates them
|
||||
# into a single dict with perDeck breakdowns and a likelyCause field so the
|
||||
# dashboard can immediately show which deck is causing timeout loops.
|
||||
draw_snapshots = getattr(state, "draw_game_snapshots", [])
|
||||
data["drawGames"] = summarize_draw_games(draw_snapshots)
|
||||
|
||||
data = _sanitize_floats(data)
|
||||
return JSONResponse(content=data)
|
||||
|
||||
@@ -269,31 +309,31 @@ async def list_history():
|
||||
AI_PROFILES = {
|
||||
"default": {
|
||||
"name": "default",
|
||||
"description": "Balanced \u2014 Forge's default AI behavior",
|
||||
"description": "Balanced — Forge's default AI behavior",
|
||||
"aggression": 0.5, "cardAdvantage": 0.5, "removalPriority": 0.5,
|
||||
"boardPresence": 0.5, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
"aggro": {
|
||||
"name": "aggro",
|
||||
"description": "Aggressive \u2014 attacks early, prioritizes damage",
|
||||
"description": "Aggressive — attacks early, prioritizes damage",
|
||||
"aggression": 0.9, "cardAdvantage": 0.3, "removalPriority": 0.3,
|
||||
"boardPresence": 0.8, "comboPriority": 0.1, "patience": 0.1,
|
||||
},
|
||||
"control": {
|
||||
"name": "control",
|
||||
"description": "Control \u2014 defensive, removal-heavy, card advantage",
|
||||
"description": "Control — defensive, removal-heavy, card advantage",
|
||||
"aggression": 0.2, "cardAdvantage": 0.9, "removalPriority": 0.9,
|
||||
"boardPresence": 0.3, "comboPriority": 0.4, "patience": 0.9,
|
||||
},
|
||||
"combo": {
|
||||
"name": "combo",
|
||||
"description": "Combo \u2014 ramps, digs for pieces, assembles combos",
|
||||
"description": "Combo — ramps, digs for pieces, assembles combos",
|
||||
"aggression": 0.2, "cardAdvantage": 0.8, "removalPriority": 0.4,
|
||||
"boardPresence": 0.3, "comboPriority": 0.95, "patience": 0.7,
|
||||
},
|
||||
"midrange": {
|
||||
"name": "midrange",
|
||||
"description": "Midrange \u2014 flexible, strong board presence, value-oriented",
|
||||
"description": "Midrange — flexible, strong board presence, value-oriented",
|
||||
"aggression": 0.5, "cardAdvantage": 0.6, "removalPriority": 0.6,
|
||||
"boardPresence": 0.7, "comboPriority": 0.3, "patience": 0.5,
|
||||
},
|
||||
@@ -469,5 +509,3 @@ async def start_batch_deepseek(request: FastAPIRequest, background_tasks: Backgr
|
||||
'message': f'Running {num_games} games across {len(decks)} decks with DeepSeek AI',
|
||||
'engine': 'deepseek',
|
||||
})
|
||||
|
||||
|
||||
+12
-4
@@ -47,7 +47,11 @@ async def install_precon(req: dict):
|
||||
raise HTTPException(500, f"Forge decks directory not found: {decks_dir}")
|
||||
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
try:
|
||||
shutil.copy2(str(src), str(dst))
|
||||
except OSError as e:
|
||||
log.error(f"Failed to copy deck file {file_name}: {e}")
|
||||
raise HTTPException(500, f"Failed to copy deck file: {e}")
|
||||
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
return {
|
||||
@@ -78,9 +82,13 @@ async def install_precons_batch(req: dict):
|
||||
results.append({"fileName": file_name, "installed": False, "error": "not found"})
|
||||
continue
|
||||
dst = Path(decks_dir) / file_name
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
try:
|
||||
shutil.copy2(str(src), str(dst))
|
||||
deck_name = file_name.replace(".dck", "")
|
||||
results.append({"fileName": file_name, "installed": True, "deckName": deck_name})
|
||||
except OSError as e:
|
||||
log.error(f"Failed to copy deck file {file_name}: {e}")
|
||||
results.append({"fileName": file_name, "installed": False, "error": str(e)})
|
||||
|
||||
return {"results": results}
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 0 — build_image_index.py
|
||||
|
||||
Builds (or rebuilds) the SQLite card image index at:
|
||||
data/card_image_index.db
|
||||
|
||||
Schema:
|
||||
card_images(
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL, -- card name, lowercased for lookup
|
||||
name_raw TEXT NOT NULL, -- original casing
|
||||
front_path TEXT, -- relative path from project root
|
||||
back_path TEXT, -- DFC back face, NULL for single-faced
|
||||
has_back INTEGER DEFAULT 0 -- 1 if DFC
|
||||
)
|
||||
CREATE INDEX idx_name ON card_images(name);
|
||||
|
||||
Usage:
|
||||
python scripts/build_image_index.py
|
||||
python scripts/build_image_index.py --rebuild
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("build_image_index")
|
||||
|
||||
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
IMAGE_DIR = PROJECT_ROOT / "static" / "card-images"
|
||||
DB_PATH = PROJECT_ROOT / "data" / "card_image_index.db"
|
||||
|
||||
CREATE_TABLE_SQL = """
|
||||
CREATE TABLE IF NOT EXISTS card_images (
|
||||
scryfall_id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
name_raw TEXT NOT NULL,
|
||||
front_path TEXT,
|
||||
back_path TEXT,
|
||||
has_back INTEGER DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_name ON card_images(name);
|
||||
"""
|
||||
|
||||
|
||||
def fetch_bulk_cards() -> list[dict]:
|
||||
log.info("Fetching Scryfall bulk manifest...")
|
||||
with urllib.request.urlopen(SCRYFALL_BULK_API, timeout=30) as r:
|
||||
meta = json.loads(r.read())
|
||||
url = meta["download_uri"]
|
||||
log.info("Downloading bulk card data...")
|
||||
with urllib.request.urlopen(url, timeout=120) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
|
||||
def build_rows(cards: list[dict]) -> list[tuple]:
|
||||
"""
|
||||
For each card, check if the image file(s) exist locally.
|
||||
Builds rows for both single-faced and DFC cards.
|
||||
Tokens are included if they have image files.
|
||||
"""
|
||||
rows: list[tuple] = []
|
||||
|
||||
for card in cards:
|
||||
scryfall_id = card.get("id", "")
|
||||
name_raw = card.get("name", "")
|
||||
if not scryfall_id or not name_raw:
|
||||
continue
|
||||
|
||||
name = name_raw.lower().strip()
|
||||
|
||||
front_file = IMAGE_DIR / f"{scryfall_id}_front.jpg"
|
||||
back_file = IMAGE_DIR / f"{scryfall_id}_back.jpg"
|
||||
|
||||
front_path = f"static/card-images/{scryfall_id}_front.jpg" if front_file.exists() else None
|
||||
back_path = f"static/card-images/{scryfall_id}_back.jpg" if back_file.exists() else None
|
||||
has_back = 1 if back_path else 0
|
||||
|
||||
rows.append((scryfall_id, name, name_raw, front_path, back_path, has_back))
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def write_db(rows: list[tuple], rebuild: bool) -> None:
|
||||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if rebuild and DB_PATH.exists():
|
||||
DB_PATH.unlink()
|
||||
log.info("Deleted existing database for rebuild.")
|
||||
|
||||
conn = sqlite3.connect(str(DB_PATH))
|
||||
try:
|
||||
conn.executescript(CREATE_TABLE_SQL)
|
||||
conn.executemany(
|
||||
"""
|
||||
INSERT OR REPLACE INTO card_images
|
||||
(scryfall_id, name, name_raw, front_path, back_path, has_back)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
rows
|
||||
)
|
||||
conn.commit()
|
||||
log.info("Indexed %d cards into %s", len(rows), DB_PATH)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build SQLite card image index.")
|
||||
parser.add_argument("--rebuild", action="store_true",
|
||||
help="Drop and rebuild the database from scratch")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
cards = fetch_bulk_cards()
|
||||
log.info("Processing %d card entries...", len(cards))
|
||||
rows = build_rows(cards)
|
||||
log.info("Found %d cards with local images.", len(rows))
|
||||
write_db(rows, args.rebuild)
|
||||
log.info("Index build complete: %s", DB_PATH)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,181 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Phase 0 — download_card_images.py
|
||||
|
||||
Bulk-downloads all MTG card images from Scryfall's default_cards bulk export.
|
||||
Stores images at: static/card-images/{scryfall_id}_front.jpg
|
||||
static/card-images/{scryfall_id}_back.jpg (DFCs only)
|
||||
|
||||
Usage:
|
||||
python scripts/download_card_images.py
|
||||
python scripts/download_card_images.py --image-size normal
|
||||
python scripts/download_card_images.py --workers 8 --resume
|
||||
|
||||
Scryfall bulk image domain has no rate limits, so no sleep is needed.
|
||||
See: https://scryfall.com/docs/api/bulk-data
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
tqdm = None
|
||||
|
||||
log = logging.getLogger("download_card_images")
|
||||
|
||||
SCRYFALL_BULK_API = "https://api.scryfall.com/bulk-data/default-cards"
|
||||
DEFAULT_IMAGE_SIZE = "normal" # 488x680px — ideal for Unity card rendering
|
||||
DEFAULT_WORKERS = 6
|
||||
|
||||
# Scryfall requires a descriptive User-Agent — returns HTTP 400 without it.
|
||||
# See: https://scryfall.com/docs/api#making-requests
|
||||
USER_AGENT = "CommanderAILab/1.0 (https://github.com/KoalaTrapLord/commander-ai-lab)"
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
OUTPUT_DIR = PROJECT_ROOT / "static" / "card-images"
|
||||
|
||||
|
||||
def _open(url: str, timeout: int = 30):
|
||||
"""urllib.request.urlopen with required Scryfall User-Agent header."""
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
return urllib.request.urlopen(req, timeout=timeout)
|
||||
|
||||
|
||||
def fetch_bulk_download_url(image_size: str) -> str:
|
||||
"""Get the Scryfall bulk data download URL for default_cards."""
|
||||
log.info("Fetching Scryfall bulk data manifest...")
|
||||
with _open(SCRYFALL_BULK_API, timeout=30) as resp:
|
||||
meta = json.loads(resp.read())
|
||||
return meta["download_uri"]
|
||||
|
||||
|
||||
def load_bulk_data(download_url: str) -> list[dict]:
|
||||
"""Stream-download and parse the bulk JSON (can be ~100MB)."""
|
||||
log.info("Downloading bulk card data from %s ...", download_url)
|
||||
with _open(download_url, timeout=120) as resp:
|
||||
cards = json.loads(resp.read())
|
||||
log.info("Loaded %d card entries.", len(cards))
|
||||
return cards
|
||||
|
||||
|
||||
def get_image_jobs(cards: list[dict], image_size: str, resume: bool) -> list[tuple[str, str]]:
|
||||
"""
|
||||
Build a list of (url, dest_path) tuples to download.
|
||||
Handles:
|
||||
- Single-faced cards: {scryfall_id}_front.jpg
|
||||
- Double-faced cards (DFCs): {scryfall_id}_front.jpg + {scryfall_id}_back.jpg
|
||||
- Tokens: uses image_uris if present, else skips gracefully
|
||||
"""
|
||||
jobs: list[tuple[str, str]] = []
|
||||
|
||||
for card in cards:
|
||||
scryfall_id = card.get("id", "")
|
||||
if not scryfall_id:
|
||||
continue
|
||||
|
||||
# Single-faced or meld cards
|
||||
if "image_uris" in card:
|
||||
url = card["image_uris"].get(image_size)
|
||||
if url:
|
||||
dest = str(OUTPUT_DIR / f"{scryfall_id}_front.jpg")
|
||||
if not resume or not Path(dest).exists():
|
||||
jobs.append((url, dest))
|
||||
|
||||
# Double-faced cards (DFCs): modal, transform, adventure, etc.
|
||||
elif "card_faces" in card:
|
||||
for idx, face in enumerate(card["card_faces"]):
|
||||
face_uris = face.get("image_uris", {})
|
||||
url = face_uris.get(image_size)
|
||||
if url:
|
||||
side = "front" if idx == 0 else "back"
|
||||
dest = str(OUTPUT_DIR / f"{scryfall_id}_{side}.jpg")
|
||||
if not resume or not Path(dest).exists():
|
||||
jobs.append((url, dest))
|
||||
|
||||
return jobs
|
||||
|
||||
|
||||
def download_image(url: str, dest: str) -> tuple[bool, str]:
|
||||
"""Download a single image. Returns (success, dest)."""
|
||||
try:
|
||||
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
||||
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||
with open(dest, "wb") as f:
|
||||
f.write(resp.read())
|
||||
return True, dest
|
||||
except Exception as exc:
|
||||
log.warning("Failed to download %s -> %s: %s", url, dest, exc)
|
||||
return False, dest
|
||||
|
||||
|
||||
def run_downloads(jobs: list[tuple[str, str]], workers: int) -> tuple[int, int]:
|
||||
"""Download all jobs concurrently. Returns (success_count, fail_count)."""
|
||||
success = 0
|
||||
fail = 0
|
||||
|
||||
iterator = tqdm(total=len(jobs), unit="img", desc="Downloading") if tqdm else None
|
||||
|
||||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||||
futures = {pool.submit(download_image, url, dest): dest for url, dest in jobs}
|
||||
for future in as_completed(futures):
|
||||
ok, _ = future.result()
|
||||
if ok:
|
||||
success += 1
|
||||
else:
|
||||
fail += 1
|
||||
if iterator:
|
||||
iterator.update(1)
|
||||
|
||||
if iterator:
|
||||
iterator.close()
|
||||
|
||||
return success, fail
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Bulk download Scryfall card images.")
|
||||
parser.add_argument("--image-size", default=DEFAULT_IMAGE_SIZE,
|
||||
choices=["small", "normal", "large", "png", "art_crop", "border_crop"],
|
||||
help="Image size to download (default: normal = 488x680px)")
|
||||
parser.add_argument("--workers", type=int, default=DEFAULT_WORKERS,
|
||||
help="Concurrent download threads (default: 6)")
|
||||
parser.add_argument("--resume", action="store_true",
|
||||
help="Skip images that already exist on disk")
|
||||
parser.add_argument("--verbose", "-v", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s"
|
||||
)
|
||||
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log.info("Output directory: %s", OUTPUT_DIR)
|
||||
|
||||
download_url = fetch_bulk_download_url(args.image_size)
|
||||
cards = load_bulk_data(download_url)
|
||||
jobs = get_image_jobs(cards, args.image_size, args.resume)
|
||||
|
||||
log.info("Jobs to download: %d (resume=%s)", len(jobs), args.resume)
|
||||
if not jobs:
|
||||
log.info("Nothing to do. All images already present.")
|
||||
return
|
||||
|
||||
success, fail = run_downloads(jobs, args.workers)
|
||||
log.info("Done. Success: %d Failed: %d Total: %d", success, fail, len(jobs))
|
||||
if fail > 0:
|
||||
log.warning("%d images failed — re-run with --resume to retry.", fail)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,4 +0,0 @@
|
||||
# Phase 6 - run_self_play.py
|
||||
# Headless self-play runner: all 4 players AI-driven, no Unity needed
|
||||
# Usage: python scripts/run_self_play.py --num-games 500 --deck-pool-path data/decks/
|
||||
# See issue #173
|
||||
+137
-26
@@ -205,35 +205,146 @@ def _save_profile_to_dck(profile: dict) -> Path:
|
||||
return out_path
|
||||
|
||||
|
||||
def _find_dck_file(deck_name: str) -> Optional[Path]:
|
||||
"""Search known deck directories for a .dck file matching deck_name.
|
||||
|
||||
Resolution order:
|
||||
1. precon_dir (precon-decks/)
|
||||
2. forge_decks_dir (Forge user decks)
|
||||
3. FORGE_DIR/res/decks/commander (Forge bundled commander decks)
|
||||
|
||||
Within each directory tries:
|
||||
a. Exact stem match (Elven_Empire.dck)
|
||||
b. Case-insensitive fuzzy match stripping underscores/spaces
|
||||
"""
|
||||
search_dirs = []
|
||||
if CFG.precon_dir and os.path.isdir(CFG.precon_dir):
|
||||
search_dirs.append(Path(CFG.precon_dir))
|
||||
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
|
||||
search_dirs.append(Path(CFG.forge_decks_dir))
|
||||
if CFG.forge_dir:
|
||||
forge_cmdr = Path(CFG.forge_dir) / "res" / "decks" / "commander"
|
||||
if forge_cmdr.is_dir():
|
||||
search_dirs.append(forge_cmdr)
|
||||
|
||||
target_norm = deck_name.lower().replace("_", "").replace(" ", "")
|
||||
|
||||
for d in search_dirs:
|
||||
exact = d / f"{deck_name}.dck"
|
||||
if exact.exists():
|
||||
return exact
|
||||
try:
|
||||
for f in d.iterdir():
|
||||
if f.suffix.lower() == ".dck":
|
||||
if f.stem.lower().replace("_", "").replace(" ", "") == target_norm:
|
||||
log_collect.info(f"[DeckService] Fuzzy .dck match: '{deck_name}' -> '{f}'")
|
||||
return f
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _load_deck_cards_from_dck(dck_path: Path) -> list:
|
||||
"""Parse a .dck file and return card dicts enriched with Forge card data.
|
||||
|
||||
Calls lookup_forge_card() for each card so that type_line, oracle_text,
|
||||
cmc, mana_cost, power, toughness, and keywords are populated from the
|
||||
Forge cardsfolder cache. This ensures Card.is_land() / is_creature()
|
||||
work correctly when a deck is loaded via the .dck fallback path (i.e.
|
||||
the deck is not yet in the SQLite DB).
|
||||
|
||||
The Forge cache is lazy-loaded and module-level, so there is no
|
||||
per-game overhead after the first deck load.
|
||||
"""
|
||||
from commander_ai_lab.sim.forge_card_loader import lookup_forge_card # lazy to avoid circular import
|
||||
|
||||
parsed = parse_dck_file(str(dck_path))
|
||||
cards = []
|
||||
|
||||
def _make_card(card_name: str, qty: int, is_commander: int) -> dict:
|
||||
forge = lookup_forge_card(card_name)
|
||||
power = ""
|
||||
toughness = ""
|
||||
if forge and "/" in forge.pt:
|
||||
parts = forge.pt.split("/", 1)
|
||||
power = parts[0].strip()
|
||||
toughness = parts[1].strip()
|
||||
return {
|
||||
"card_name": card_name,
|
||||
"name": card_name,
|
||||
"quantity": qty,
|
||||
"is_commander": is_commander,
|
||||
"type_line": forge.type_line if forge else "",
|
||||
"oracle_text": forge.oracle_text if forge else "",
|
||||
"cmc": forge.cmc if forge else 0,
|
||||
"mana_cost": forge.mana_cost if forge else "",
|
||||
"power": power,
|
||||
"toughness": toughness,
|
||||
"keywords": json.dumps(forge.keywords) if forge else "[]",
|
||||
"color_identity": "[]",
|
||||
}
|
||||
|
||||
for card_name, qty in parsed.get("commanders", {}).items():
|
||||
cards.append(_make_card(card_name, qty, 1))
|
||||
for card_name, qty in parsed.get("mainboard", {}).items():
|
||||
cards.append(_make_card(card_name, qty, 0))
|
||||
|
||||
enriched = sum(1 for c in cards if c["type_line"])
|
||||
log_collect.info(
|
||||
"[DeckService] .dck load: %d cards, %d enriched from Forge cache (%s)",
|
||||
len(cards), enriched, dck_path.name,
|
||||
)
|
||||
return cards
|
||||
|
||||
|
||||
def _load_deck_cards_by_name(deck_name: str) -> list:
|
||||
"""Load cards from a deck by name, returning list of card dicts."""
|
||||
"""Load cards from a deck by name, returning list of card dicts.
|
||||
|
||||
Resolution order:
|
||||
1. SQLite DB (decks built/imported via the Deck Builder UI)
|
||||
2. .dck file fallback -- searches precon_dir, forge_decks_dir, and
|
||||
FORGE_DIR/res/decks/commander so that precon decks work without
|
||||
needing to be manually imported into the DB first.
|
||||
"""
|
||||
# -- 1. DB lookup --
|
||||
conn = _get_db_conn()
|
||||
deck_row = conn.execute("SELECT id FROM decks WHERE name = ?", (deck_name,)).fetchone()
|
||||
if not deck_row:
|
||||
return []
|
||||
deck_id = deck_row[0]
|
||||
rows = conn.execute("""
|
||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
|
||||
COALESCE(ce.mana_cost, '', '') as mana_cost,
|
||||
COALESCE(ce.power, '', '') as power,
|
||||
COALESCE(ce.toughness, '', '') as toughness,
|
||||
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
|
||||
FROM card_records GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
if deck_row:
|
||||
deck_id = deck_row[0]
|
||||
rows = conn.execute("""
|
||||
SELECT dc.card_name, dc.quantity, dc.is_commander, dc.role_tag, dc.scryfall_id,
|
||||
COALESCE(ce.type_line, cr.type_line, '') as type_line,
|
||||
COALESCE(ce.oracle_text, cr.oracle_text, '') as oracle_text,
|
||||
COALESCE(ce.cmc, cr.cmc, 0) as cmc,
|
||||
COALESCE(ce.color_identity, cr.color_identity, '[]') as color_identity,
|
||||
COALESCE(ce.mana_cost, '', '') as mana_cost,
|
||||
COALESCE(ce.power, '', '') as power,
|
||||
COALESCE(ce.toughness, '', '') as toughness,
|
||||
COALESCE(ce.keywords, cr.keywords, '[]') as keywords
|
||||
FROM deck_cards dc
|
||||
LEFT JOIN (
|
||||
SELECT scryfall_id, type_line, oracle_text, cmc, color_identity, mana_cost, power, toughness, keywords
|
||||
FROM collection_entries GROUP BY scryfall_id
|
||||
) ce ON ce.scryfall_id = dc.scryfall_id
|
||||
LEFT JOIN (
|
||||
SELECT name, type_line, oracle_text, cmc, color_identity, keywords
|
||||
FROM card_records GROUP BY name
|
||||
) cr ON cr.name = dc.card_name
|
||||
WHERE dc.deck_id = ?
|
||||
""", (deck_id,)).fetchall()
|
||||
return [_row_to_dict(r) for r in rows]
|
||||
|
||||
# -- 2. .dck file fallback --
|
||||
dck_path = _find_dck_file(deck_name)
|
||||
if dck_path:
|
||||
log_collect.info(
|
||||
f"[DeckService] '{deck_name}' not in DB -- loading from .dck file: {dck_path}"
|
||||
)
|
||||
return _load_deck_cards_from_dck(dck_path)
|
||||
|
||||
log_collect.warning(f"[DeckService] Deck '{deck_name}' not found in DB or any .dck search path.")
|
||||
return []
|
||||
|
||||
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen")
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
services/draw_game_analyzer.py
|
||||
==============================
|
||||
Analyses draw-game snapshots captured by forge_runner._run_process_blocking.
|
||||
|
||||
A "draw game" is any game that hit the Forge per-game wall-clock timeout
|
||||
instead of ending via a win condition. Forge emits a line like:
|
||||
|
||||
[DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200 \
|
||||
seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0 \
|
||||
seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
|
||||
|
||||
This module:
|
||||
1. Parses those lines out of the log into DrawGameSnapshot objects.
|
||||
2. Aggregates them to identify which deck most often leads at timeout.
|
||||
3. Exposes a summary dict suitable for embedding in the batch result JSON.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
# ─── regex for the Forge DRAW summary line ────────────────────────────────────
|
||||
# Example:
|
||||
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
|
||||
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
|
||||
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
|
||||
_DRAW_LINE_RE = re.compile(
|
||||
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
|
||||
r'.*?game=(\d+)'
|
||||
r'.*?turns=(\d+)'
|
||||
r'.*?time_ms=(\d+)'
|
||||
r'((?:\s+seat\d+=\S+)*)'
|
||||
, re.IGNORECASE
|
||||
)
|
||||
_SEAT_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrawGameSnapshot:
|
||||
game_id: int
|
||||
turns: int
|
||||
time_ms: int
|
||||
# seat_index -> {deck_name, life}
|
||||
seats: Dict[int, dict] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def leading_deck(self) -> Optional[str]:
|
||||
"""Name of the deck with the highest life at timeout (None if tie/empty)."""
|
||||
if not self.seats:
|
||||
return None
|
||||
best = max(self.seats.values(), key=lambda s: s["life"])
|
||||
return best["deck_name"]
|
||||
|
||||
@property
|
||||
def leading_life(self) -> int:
|
||||
if not self.seats:
|
||||
return 0
|
||||
return max(s["life"] for s in self.seats.values())
|
||||
|
||||
|
||||
def parse_draw_snapshots(log_lines: list[str]) -> list[DrawGameSnapshot]:
|
||||
"""
|
||||
Scan raw log lines for [DRAW] [PARSE-SUMMARY] entries and return
|
||||
a list of DrawGameSnapshot objects, one per draw game.
|
||||
"""
|
||||
snapshots: list[DrawGameSnapshot] = []
|
||||
for line in log_lines:
|
||||
m = _DRAW_LINE_RE.search(line)
|
||||
if not m:
|
||||
continue
|
||||
game_id = int(m.group(1))
|
||||
turns = int(m.group(2))
|
||||
time_ms = int(m.group(3))
|
||||
seats_str = m.group(4) or ""
|
||||
seats: Dict[int, dict] = {}
|
||||
for sm in _SEAT_RE.finditer(seats_str):
|
||||
seat_idx = int(sm.group(1))
|
||||
deck_name = sm.group(2).strip()
|
||||
life = int(sm.group(3))
|
||||
seats[seat_idx] = {"deck_name": deck_name, "life": life}
|
||||
snapshots.append(DrawGameSnapshot(game_id=game_id, turns=turns, time_ms=time_ms, seats=seats))
|
||||
return snapshots
|
||||
|
||||
|
||||
def summarize_draw_games(snapshots: list[DrawGameSnapshot]) -> dict:
|
||||
"""
|
||||
Aggregate draw snapshots into a summary dict for embedding in batch JSON.
|
||||
|
||||
Returns::
|
||||
|
||||
{
|
||||
"count": 156,
|
||||
"avgTurns": 27.4,
|
||||
"avgTimeMs": 61800,
|
||||
"perDeck": [
|
||||
{
|
||||
"deckName": "AdaptiveEnchantment",
|
||||
"drawLeadCount": 89, # times this deck led at timeout
|
||||
"drawLeadPct": 57.1,
|
||||
"avgLifeAtTimeout": 42.3,
|
||||
},
|
||||
...
|
||||
],
|
||||
"likelyCause": "AdaptiveEnchantment" # deck most often leading at timeout
|
||||
}
|
||||
"""
|
||||
if not snapshots:
|
||||
return {"count": 0, "avgTurns": 0, "avgTimeMs": 0, "perDeck": [], "likelyCause": None}
|
||||
|
||||
total = len(snapshots)
|
||||
avg_turns = round(sum(s.turns for s in snapshots) / total, 1)
|
||||
avg_time_ms = round(sum(s.time_ms for s in snapshots) / total)
|
||||
|
||||
# Accumulate per-deck stats across all draw games
|
||||
deck_lead_count: Dict[str, int] = {}
|
||||
deck_life_sum: Dict[str, float] = {}
|
||||
deck_life_count: Dict[str, int] = {}
|
||||
|
||||
for snap in snapshots:
|
||||
leader = snap.leading_deck
|
||||
if leader:
|
||||
deck_lead_count[leader] = deck_lead_count.get(leader, 0) + 1
|
||||
for seat in snap.seats.values():
|
||||
dn = seat["deck_name"]
|
||||
deck_life_sum[dn] = deck_life_sum.get(dn, 0.0) + seat["life"]
|
||||
deck_life_count[dn] = deck_life_count.get(dn, 0) + 1
|
||||
|
||||
all_decks = set(deck_life_count.keys()) | set(deck_lead_count.keys())
|
||||
per_deck = []
|
||||
for dn in sorted(all_decks):
|
||||
lead_cnt = deck_lead_count.get(dn, 0)
|
||||
avg_life = round(deck_life_sum.get(dn, 0) / max(deck_life_count.get(dn, 1), 1), 1)
|
||||
per_deck.append({
|
||||
"deckName": dn,
|
||||
"drawLeadCount": lead_cnt,
|
||||
"drawLeadPct": round(lead_cnt / total * 100, 1),
|
||||
"avgLifeAtTimeout": avg_life,
|
||||
})
|
||||
|
||||
per_deck.sort(key=lambda x: x["drawLeadCount"], reverse=True)
|
||||
likely_cause = per_deck[0]["deckName"] if per_deck else None
|
||||
|
||||
return {
|
||||
"count": total,
|
||||
"avgTurns": avg_turns,
|
||||
"avgTimeMs": avg_time_ms,
|
||||
"perDeck": per_deck,
|
||||
"likelyCause": likely_cause,
|
||||
}
|
||||
+164
-7
@@ -168,6 +168,62 @@ def reset_java17_cache():
|
||||
_JAVA17_PATH = None
|
||||
|
||||
|
||||
# ── Deck path resolution ──────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_deck_path(deck_name: str) -> str:
|
||||
"""Resolve a deck name to its full .dck path in CFG.precon_dir.
|
||||
|
||||
Resolution order:
|
||||
1. Already an absolute path with .dck extension — return as-is.
|
||||
2. Exact filename match in precon_dir (e.g. "Abzan_Armor.dck").
|
||||
3. camelCase → snake_case conversion (e.g. "AbzanArmor" → "Abzan_Armor.dck").
|
||||
4. Space → underscore normalization.
|
||||
5. Case-insensitive fuzzy match stripping underscores/spaces.
|
||||
6. Fallback: return raw name and let the JAR fail with a clear error.
|
||||
"""
|
||||
# Already a resolved path
|
||||
if deck_name.endswith('.dck') and os.path.isfile(deck_name):
|
||||
return deck_name
|
||||
|
||||
precon_dir = CFG.precon_dir
|
||||
if not precon_dir or not os.path.isdir(precon_dir):
|
||||
log.warning(f"[DeckResolver] precon_dir not set or missing — passing raw name '{deck_name}' to JAR")
|
||||
return deck_name
|
||||
|
||||
# 1. Exact match
|
||||
exact = os.path.join(precon_dir, f"{deck_name}.dck")
|
||||
if os.path.isfile(exact):
|
||||
return exact
|
||||
|
||||
# 2. camelCase → snake_case (AbzanArmor → Abzan_Armor)
|
||||
snake = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '_', deck_name)
|
||||
snake_path = os.path.join(precon_dir, f"{snake}.dck")
|
||||
if os.path.isfile(snake_path):
|
||||
return snake_path
|
||||
|
||||
# 3. Space → underscore (e.g. "Abzan Armor" → "Abzan_Armor.dck")
|
||||
spaced = deck_name.replace(' ', '_')
|
||||
spaced_path = os.path.join(precon_dir, f"{spaced}.dck")
|
||||
if os.path.isfile(spaced_path):
|
||||
return spaced_path
|
||||
|
||||
# 4. Case-insensitive fuzzy: strip underscores/spaces, compare lowercased stems
|
||||
target = deck_name.lower().replace('_', '').replace(' ', '')
|
||||
try:
|
||||
for fname in os.listdir(precon_dir):
|
||||
if fname.lower().endswith('.dck'):
|
||||
stem = fname[:-4].lower().replace('_', '').replace(' ', '')
|
||||
if stem == target:
|
||||
resolved = os.path.join(precon_dir, fname)
|
||||
log.info(f"[DeckResolver] Fuzzy match: '{deck_name}' → '{fname}'")
|
||||
return resolved
|
||||
except OSError as e:
|
||||
log.warning(f"[DeckResolver] Could not list precon_dir '{precon_dir}': {e}")
|
||||
|
||||
log.warning(f"[DeckResolver] Deck not found: '{deck_name}' (searched {precon_dir}) — passing raw name to JAR")
|
||||
return deck_name
|
||||
|
||||
|
||||
def build_java_command(
|
||||
decks: list, num_games: int, threads: int, seed: Optional[int],
|
||||
clock: int, output_path: str, use_learned_policy: bool = False,
|
||||
@@ -191,9 +247,11 @@ def build_java_command(
|
||||
'--clock', str(clock),
|
||||
'--output', output_path,
|
||||
]
|
||||
# Map deck list to --deck1 .. --deck4
|
||||
# Map deck list to --deck1 .. --deck4, resolving names to full .dck paths
|
||||
for i, d in enumerate(decks[:4], start=1):
|
||||
cmd += [f'--deck{i}', str(d)]
|
||||
resolved = _resolve_deck_path(str(d))
|
||||
log.info(f"[DeckResolver] deck{i}: '{d}' → '{resolved}'")
|
||||
cmd += [f'--deck{i}', resolved]
|
||||
if seed is not None:
|
||||
cmd += ['--seed', str(seed)]
|
||||
if use_learned_policy:
|
||||
@@ -210,10 +268,50 @@ def build_java_command(
|
||||
return cmd
|
||||
|
||||
|
||||
# ── Draw-game life-at-timeout parser ─────────────────────────────────────────
|
||||
# Forge emits a summary line for each game that hits the wall-clock timeout:
|
||||
#
|
||||
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
|
||||
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
|
||||
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
|
||||
#
|
||||
# We capture these live so they're available before the process finishes.
|
||||
_DRAW_SUMMARY_RE = re.compile(
|
||||
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
|
||||
r'.*?game=(\d+)'
|
||||
r'.*?turns=(\d+)'
|
||||
r'.*?time_ms=(\d+)'
|
||||
r'((?:\s+seat\d+=\S+)*)'
|
||||
, re.IGNORECASE
|
||||
)
|
||||
_SEAT_LIFE_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
|
||||
|
||||
|
||||
def _parse_draw_summary_line(line: str) -> Optional[dict]:
|
||||
"""Return a draw-game snapshot dict if line matches, else None."""
|
||||
m = _DRAW_SUMMARY_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
seats = {}
|
||||
for sm in _SEAT_LIFE_RE.finditer(m.group(4) or ""):
|
||||
seat_idx = int(sm.group(1))
|
||||
seats[seat_idx] = {"deck_name": sm.group(2).strip(), "life": int(sm.group(3))}
|
||||
return {
|
||||
"game_id": int(m.group(1)),
|
||||
"turns": int(m.group(2)),
|
||||
"time_ms": int(m.group(3)),
|
||||
"seats": seats,
|
||||
}
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list):
|
||||
"""Run Forge subprocess, parse progress, generate deck reports."""
|
||||
"""Run Forge subprocess, parse progress, collect draw-game snapshots."""
|
||||
log.info(f"Running: {' '.join(cmd)}")
|
||||
|
||||
# Forge must run with forge_dir as cwd so it can find res/ relative to itself
|
||||
forge_cwd = CFG.forge_dir if CFG.forge_dir and os.path.isdir(CFG.forge_dir) else None
|
||||
log.info(f"[ForgeRunner] cwd={forge_cwd}")
|
||||
|
||||
# Ensure Forge subprocesses use Java 17
|
||||
env = os.environ.copy()
|
||||
java17 = get_java17()
|
||||
@@ -226,7 +324,7 @@ def _run_process_blocking(state: BatchState, cmd: list):
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, bufsize=1, env=env,
|
||||
text=True, bufsize=1, env=env, cwd=forge_cwd,
|
||||
)
|
||||
state.process = proc
|
||||
except Exception as e:
|
||||
@@ -237,10 +335,15 @@ def _run_process_blocking(state: BatchState, cmd: list):
|
||||
|
||||
_register_batch(state.batch_id, state, proc)
|
||||
|
||||
# ── Live stdout parsing ───────────────────────────────────────────
|
||||
draw_snapshots: list = []
|
||||
|
||||
for line in proc.stdout:
|
||||
_touch_batch(state.batch_id)
|
||||
line = line.rstrip()
|
||||
state.log_lines.append(line)
|
||||
|
||||
# Progress counters
|
||||
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
|
||||
if gm:
|
||||
state.completed_games = int(gm.group(1))
|
||||
@@ -249,19 +352,46 @@ def _run_process_blocking(state: BatchState, cmd: list):
|
||||
if pm:
|
||||
state.sims_per_sec = float(pm.group(1))
|
||||
|
||||
# ── Draw-game capture (fix #176) ──────────────────────────────
|
||||
snap = _parse_draw_summary_line(line)
|
||||
if snap:
|
||||
draw_snapshots.append(snap)
|
||||
|
||||
proc.wait()
|
||||
_unregister_batch(state.batch_id)
|
||||
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
|
||||
state.elapsed_ms = int(elapsed)
|
||||
|
||||
# Persist draw snapshots on state for routes/lab.py to embed in JSON
|
||||
state.draw_game_snapshots = draw_snapshots
|
||||
if draw_snapshots:
|
||||
log.info(
|
||||
f"[DrawGames] Captured {len(draw_snapshots)} draw-game snapshots "
|
||||
f"for batch {state.batch_id}"
|
||||
)
|
||||
|
||||
if proc.returncode != 0:
|
||||
state.running = False
|
||||
# Include last few log lines in error for easier diagnosis
|
||||
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
|
||||
state.error = f"Exit code {proc.returncode} — last output: {tail}"
|
||||
else:
|
||||
state.running = False
|
||||
state.completed_games = state.total_games
|
||||
|
||||
# ── Post-process: detect alternate win conditions in the Java log ──
|
||||
try:
|
||||
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
|
||||
src_dir = os.path.normpath(src_dir)
|
||||
if src_dir not in sys.path:
|
||||
sys.path.insert(0, src_dir)
|
||||
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
|
||||
state.win_type_override = detect_win_type_from_log(state.log_lines)
|
||||
if state.win_type_override != "Combat":
|
||||
log.info(f"[WinType] Alternate win detected in batch {state.batch_id}: {state.win_type_override}")
|
||||
except Exception as _wcp_err:
|
||||
log.debug(f"[WinType] Parser import failed (non-fatal): {_wcp_err}")
|
||||
state.win_type_override = "Combat"
|
||||
|
||||
|
||||
async def run_batch_subprocess(
|
||||
state: BatchState,
|
||||
@@ -269,7 +399,7 @@ async def run_batch_subprocess(
|
||||
num_games: int,
|
||||
threads: int,
|
||||
seed: Optional[int] = None,
|
||||
clock: int = 6000,
|
||||
clock: int = 3000,
|
||||
output_path: str = "results",
|
||||
use_learned_policy: bool = False,
|
||||
policy_style: str = "midrange",
|
||||
@@ -278,7 +408,12 @@ async def run_batch_subprocess(
|
||||
ai_think_time_ms: int = -1,
|
||||
max_queue_depth: int = -1,
|
||||
):
|
||||
"""Async wrapper that runs Forge subprocess in executor."""
|
||||
"""Async wrapper that runs Forge subprocess in executor.
|
||||
|
||||
clock default lowered from 6000 → 3000 (30s per-game wall limit for batch
|
||||
mode) to reduce draw-game wall time wasted on timeouts (fix #176).
|
||||
Interactive games pass an explicit req.clock override so they are unaffected.
|
||||
"""
|
||||
try:
|
||||
policy_server = f"http://localhost:{CFG.port}"
|
||||
cmd = build_java_command(
|
||||
@@ -344,6 +479,7 @@ def _run_deepseek_batch_thread(
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_cards
|
||||
|
||||
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
|
||||
brain = _get_deepseek_brain()
|
||||
@@ -503,6 +639,27 @@ def _run_deepseek_batch_thread(
|
||||
)
|
||||
gd = result.to_dict()
|
||||
gd['gameNumber'] = g + 1
|
||||
|
||||
# ── Alternate win detection (DeepSeek path) ──────────────
|
||||
try:
|
||||
winner_seat = result.winner_seat
|
||||
if winner_seat >= 0:
|
||||
winner_bf = getattr(result, '_winner_battlefield', None)
|
||||
if winner_bf is not None:
|
||||
win_type = detect_win_type_from_cards(winner_bf)
|
||||
else:
|
||||
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
|
||||
win_type = detect_win_type_from_log(
|
||||
[str(entry) for entry in (result.game_log or [])]
|
||||
)
|
||||
result.win_type = win_type
|
||||
gd['winType'] = win_type
|
||||
if win_type != 'Combat':
|
||||
log.info(f'[WinType] {deck_name} game {g+1}: {win_type} win detected')
|
||||
except Exception as _wt_err:
|
||||
log.debug(f'[WinType] Detection failed for game {g+1} (non-fatal): {_wt_err}')
|
||||
gd.setdefault('winType', 'Combat')
|
||||
|
||||
deck_stats['games'].append(gd)
|
||||
|
||||
if result.winner == 0:
|
||||
|
||||
@@ -354,11 +354,16 @@ class GameResult:
|
||||
|
||||
- players: list of PlayerResult, one per seat
|
||||
- winner_seat: index of the winning player (-1 = draw)
|
||||
- win_type: how the game was won — "Combat", "Combo", "Drain", "Mill",
|
||||
"Poison", or "CmdrDmg". Defaults to "Combat".
|
||||
Set by win_condition_parser.detect_win_type_from_cards() (Python engine)
|
||||
or win_condition_parser.detect_win_type_from_log() (Java/Forge engine).
|
||||
- Backward-compat properties player_a_stats / player_b_stats delegate to players[0] / players[1]
|
||||
"""
|
||||
|
||||
winner_seat: int = -1
|
||||
turns: int = 0
|
||||
win_type: str = "Combat" # populated by win_condition_parser
|
||||
players: list[PlayerResult] = field(default_factory=list)
|
||||
game_log: list = field(default_factory=list)
|
||||
|
||||
@@ -428,6 +433,7 @@ class GameResult:
|
||||
# Legacy keys (UI expects these)
|
||||
"winner": self.winner_seat,
|
||||
"turns": self.turns,
|
||||
"winType": self.win_type,
|
||||
# New N-player keys (Java BatchResult schema)
|
||||
"winningSeat": self.winner_seat,
|
||||
"playerResults": [p.to_dict() for p in self.players],
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Commander AI Lab — Alternate Win Condition Parser
|
||||
==================================================
|
||||
Scans Forge log lines or oracle text for non-combat "you win the game"
|
||||
triggers and returns a win type category string.
|
||||
|
||||
Used by:
|
||||
- services/forge_runner.py (Java batch path: post-process log_lines)
|
||||
- src/commander_ai_lab/sim/deepseek_engine.py (DeepSeek path: check oracle text)
|
||||
- src/commander_ai_lab/sim/engine.py (Python sim path: check oracle text)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alternate win card registry
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maps lowercase card name -> win category.
|
||||
# Categories: "Combo", "Drain", "Mill", "Poison"
|
||||
# "Combat" is the default and should never appear here.
|
||||
|
||||
ALTERNATE_WIN_CARDS: dict[str, str] = {
|
||||
# Spell-based
|
||||
"approach of the second sun": "Combo",
|
||||
"thassa's oracle": "Combo",
|
||||
"jace, wielder of mysteries": "Combo",
|
||||
"laboratory maniac": "Combo",
|
||||
"leveler": "Combo", # pairs with Lab Man / Thassa
|
||||
"gleemax": "Combo",
|
||||
"coalition victory": "Combo",
|
||||
"helix pinnacle": "Combo",
|
||||
"mayael's aria": "Combo",
|
||||
"mechanized production": "Combo",
|
||||
"happily ever after": "Combo",
|
||||
"simic ascendancy": "Combo",
|
||||
"azor's elocutors": "Combo",
|
||||
"chance encounter": "Combo",
|
||||
"darksteel reactor": "Combo",
|
||||
"hellkite tyrant": "Combo",
|
||||
"mortal kombat": "Combo",
|
||||
"revel in riches": "Drain",
|
||||
"test of endurance": "Combo",
|
||||
"felidar sovereign": "Combo",
|
||||
"celestial convergence": "Combo",
|
||||
"barren glory": "Combo",
|
||||
"maze's end": "Combo",
|
||||
"new horizons": "Combo",
|
||||
"thespian's stage": "Combo", # copying Maze's End
|
||||
# Poison / Infect
|
||||
"phyrexian unlife": "Poison",
|
||||
"poisoned tips": "Poison",
|
||||
# Mill
|
||||
"milling": "Mill", # generic tag
|
||||
"the fall of lord karsus": "Mill",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Forge log scanner
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Matches lines like:
|
||||
# [COMBAT-LINE] Resolve Stack: Approach of the Second Sun ... you win the game.
|
||||
# [OUTCOME-LINE] ... you win the game ...
|
||||
# Game Result: Player won (Approach of the Second Sun)
|
||||
_YOU_WIN_RE = re.compile(
|
||||
r'you win the game',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Extracts a card name from the line so we can look it up in ALTERNATE_WIN_CARDS.
|
||||
# Forge typically formats these as "Resolve Stack: <CardName> ..." or
|
||||
# "Player won via <CardName>".
|
||||
_CARD_NAME_RE = re.compile(
|
||||
r'(?:Resolve Stack|via|won by|triggered by|winning card)[:\s]+([A-Za-z\'\,\.\- ]+)',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def detect_win_type_from_log(log_lines: list[str]) -> str:
|
||||
"""
|
||||
Scan a list of Forge stdout log lines for alternate win condition triggers.
|
||||
|
||||
Returns the win category ("Combo", "Drain", "Mill", "Poison") if an
|
||||
alternate win card is detected, otherwise returns "Combat".
|
||||
|
||||
Scans *all* lines so the last detected alternate win takes precedence
|
||||
(handles edge cases where the log mentions multiple near-wins).
|
||||
"""
|
||||
result = "Combat"
|
||||
for line in log_lines:
|
||||
if not _YOU_WIN_RE.search(line):
|
||||
continue
|
||||
# Try to extract the card name responsible
|
||||
m = _CARD_NAME_RE.search(line)
|
||||
if m:
|
||||
card_name = m.group(1).strip().rstrip('.').lower()
|
||||
# Exact lookup first
|
||||
if card_name in ALTERNATE_WIN_CARDS:
|
||||
result = ALTERNATE_WIN_CARDS[card_name]
|
||||
continue
|
||||
# Partial / substring match for long card names
|
||||
for known, category in ALTERNATE_WIN_CARDS.items():
|
||||
if known in card_name or card_name in known:
|
||||
result = category
|
||||
break
|
||||
else:
|
||||
# No card name found but "you win the game" is present —
|
||||
# scan the full line for any known card name
|
||||
line_lower = line.lower()
|
||||
for known, category in ALTERNATE_WIN_CARDS.items():
|
||||
if known in line_lower:
|
||||
result = category
|
||||
break
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Oracle text scanner (Python / DeepSeek engine path)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def detect_win_type_from_cards(cards_on_battlefield: list) -> str:
|
||||
"""
|
||||
Inspect a player's battlefield (list of Card objects) for alternate win
|
||||
permanents. Returns the win category or "Combat".
|
||||
|
||||
Checks:
|
||||
1. Card name against ALTERNATE_WIN_CARDS (fastest)
|
||||
2. oracle_text for "you win the game" as a safety net
|
||||
"""
|
||||
for card in cards_on_battlefield:
|
||||
name_lower = getattr(card, 'name', '').lower()
|
||||
if name_lower in ALTERNATE_WIN_CARDS:
|
||||
return ALTERNATE_WIN_CARDS[name_lower]
|
||||
oracle = getattr(card, 'oracle_text', '') or ''
|
||||
if 'you win the game' in oracle.lower():
|
||||
# Unknown alternate win card — still not Combat
|
||||
return "Combo"
|
||||
return "Combat"
|
||||
|
||||
|
||||
def detect_win_type_from_oracle(oracle_text: str, card_name: str = "") -> str:
|
||||
"""
|
||||
Simple oracle text probe used when we have a resolved spell/ability
|
||||
but not a full battlefield list.
|
||||
"""
|
||||
if card_name:
|
||||
lookup = card_name.strip().lower()
|
||||
if lookup in ALTERNATE_WIN_CARDS:
|
||||
return ALTERNATE_WIN_CARDS[lookup]
|
||||
if 'you win the game' in (oracle_text or '').lower():
|
||||
return "Combo"
|
||||
return "Combat"
|
||||
File diff suppressed because it is too large.
Load diff
+61
-25
@@ -7,33 +7,63 @@ REM Starts the Python FastAPI server + serves the web UI.
|
||||
REM Prerequisites:
|
||||
REM - Python 3.10+ with: pip install fastapi uvicorn
|
||||
REM - Java 17+
|
||||
REM - commander-ai-lab built: mvn package "-DskipTests"
|
||||
REM - Forge built: mvn package "-DskipTests"
|
||||
REM
|
||||
REM Edit the paths below to match your system.
|
||||
REM All paths are resolved RELATIVE to this script's location.
|
||||
REM No hardcoded drive letters — works on any machine.
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM ── Your Forge paths (EDIT THESE) ──────────────────────────
|
||||
set FORGE_JAR=D:\ForgeCommander\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
set FORGE_DIR=D:\ForgeCommander\forge-repo
|
||||
set FORGE_DECKS_DIR=%APPDATA%\Forge\decks\commander
|
||||
REM ── Lab JAR (auto-detected from target/) ─────────────────────
|
||||
|
||||
REM ── Resolve root from script location (portable, any drive) ─
|
||||
set SCRIPT_DIR=%~dp0
|
||||
set FORGE_COMMANDER_ROOT=%SCRIPT_DIR%..
|
||||
|
||||
REM ── Forge paths (auto-resolved, no hardcoded drive) ─────────
|
||||
set FORGE_JAR=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\forge-gui-desktop-2.0.12-SNAPSHOT-jar-with-dependencies.jar
|
||||
set FORGE_DIR=%FORGE_COMMANDER_ROOT%\forge-repo
|
||||
set FORGE_PROFILE=%FORGE_COMMANDER_ROOT%\forge-repo\forge-gui-desktop\target\res\forge.profile.properties
|
||||
|
||||
REM ── Deck paths: all inside forge-repo for full portability ──
|
||||
set FORGE_USER_DATA=%FORGE_COMMANDER_ROOT%\forge-repo\userdata
|
||||
set FORGE_DECKS_DIR=%FORGE_USER_DATA%\decks
|
||||
set PRECON_DECKS_DIR=%SCRIPT_DIR%precon-decks
|
||||
|
||||
REM ── Lab server port ──────────────────────────────────────────
|
||||
set LAB_PORT=8080
|
||||
|
||||
REM ── API Keys (set via environment variables or .env file) ──
|
||||
REM If not already set in your environment, create a .env file
|
||||
REM in this directory (see .env.example) or set them manually:
|
||||
REM set XIMILAR_API_KEY=your-ximilar-key-here
|
||||
REM set ANTHROPIC_API_KEY=your-anthropic-api-key-here
|
||||
REM
|
||||
REM Load from .env file if it exists and vars aren't already set
|
||||
if not defined XIMILAR_API_KEY (
|
||||
if exist "%~dp0.env" (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("%~dp0.env") do (
|
||||
if exist "%SCRIPT_DIR%.env" (
|
||||
for /f "usebackq tokens=1,* delims==" %%A in ("%SCRIPT_DIR%.env") do (
|
||||
if "%%A"=="XIMILAR_API_KEY" set XIMILAR_API_KEY=%%B
|
||||
if "%%A"=="ANTHROPIC_API_KEY" set ANTHROPIC_API_KEY=%%B
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
REM Write forge.profile.properties so Forge resolves decks from
|
||||
REM forge-repo\userdata instead of %APPDATA%\Forge.
|
||||
REM Backslashes are doubled as required by .properties format.
|
||||
REM This is rewritten every launch so it always matches the
|
||||
REM current machine's absolute path.
|
||||
REM ═══════════════════════════════════════════════════════════
|
||||
mkdir "%FORGE_USER_DATA%\decks\commander" 2>nul
|
||||
|
||||
REM Build escaped paths for .properties file
|
||||
set _UD=%FORGE_USER_DATA%
|
||||
set _DD=%FORGE_DECKS_DIR%
|
||||
setlocal enabledelayedexpansion
|
||||
set _UD_ESC=!_UD:\=\\!
|
||||
set _DD_ESC=!_DD:\=\\!
|
||||
(echo userDir=!_UD_ESC!) > "%FORGE_PROFILE%"
|
||||
(echo decksDir=!_DD_ESC!) >> "%FORGE_PROFILE%"
|
||||
endlocal
|
||||
|
||||
REM ── Copy precon .dck files into Forge's commander deck folder
|
||||
REM (idempotent — safe to run every launch)
|
||||
xcopy /Y /Q "%PRECON_DECKS_DIR%\*.dck" "%FORGE_DECKS_DIR%\commander\" >nul 2>&1
|
||||
echo Synced precon decks to Forge commander folder.
|
||||
|
||||
echo.
|
||||
echo ╔══════════════════════════════════════════════════╗
|
||||
echo ║ Commander AI Lab — Launcher ║
|
||||
@@ -42,12 +72,17 @@ echo.
|
||||
echo Forge JAR: %FORGE_JAR%
|
||||
echo Forge Dir: %FORGE_DIR%
|
||||
echo Decks Dir: %FORGE_DECKS_DIR%
|
||||
echo Precon Dir: %PRECON_DECKS_DIR%
|
||||
echo Port: %LAB_PORT%
|
||||
echo Ximilar: Configured
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
echo Claude Opus: Configured
|
||||
if defined XIMILAR_API_KEY (
|
||||
echo Ximilar: Configured
|
||||
) else (
|
||||
echo Claude Opus: Not configured ^(set ANTHROPIC_API_KEY env var for AI features^)
|
||||
echo Ximilar: Not configured
|
||||
)
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
echo Claude: Configured
|
||||
) else (
|
||||
echo Claude: Not configured ^(set ANTHROPIC_API_KEY for AI features^)
|
||||
)
|
||||
echo.
|
||||
|
||||
@@ -69,15 +104,14 @@ if %ERRORLEVEL% neq 0 (
|
||||
if not exist "%FORGE_JAR%" (
|
||||
echo ERROR: Forge JAR not found at: %FORGE_JAR%
|
||||
echo Run Forge build first:
|
||||
echo cd D:\ForgeCommander\forge-repo
|
||||
echo cd %FORGE_DIR%
|
||||
echo mvn package -pl forge-gui-desktop "-DskipTests" "-Drevision=2.0.12-SNAPSHOT" -am
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM ── Check if lab_api.py exists ─────────────────────────────
|
||||
if not exist "%~dp0lab_api.py" (
|
||||
echo ERROR: lab_api.py not found in %~dp0
|
||||
if not exist "%SCRIPT_DIR%lab_api.py" (
|
||||
echo ERROR: lab_api.py not found in %SCRIPT_DIR%
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
@@ -98,18 +132,20 @@ echo Press Ctrl+C to stop.
|
||||
echo.
|
||||
|
||||
if defined ANTHROPIC_API_KEY (
|
||||
python "%~dp0lab_api.py" ^
|
||||
python "%SCRIPT_DIR%lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
--forge-dir "%FORGE_DIR%" ^
|
||||
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
||||
--precon-dir "%PRECON_DECKS_DIR%" ^
|
||||
--port %LAB_PORT% ^
|
||||
--ximilar-key "%XIMILAR_API_KEY%" ^
|
||||
--anthropic-key "%ANTHROPIC_API_KEY%"
|
||||
) else (
|
||||
python "%~dp0lab_api.py" ^
|
||||
python "%SCRIPT_DIR%lab_api.py" ^
|
||||
--forge-jar "%FORGE_JAR%" ^
|
||||
--forge-dir "%FORGE_DIR%" ^
|
||||
--forge-decks-dir "%FORGE_DECKS_DIR%" ^
|
||||
--precon-dir "%PRECON_DECKS_DIR%" ^
|
||||
--port %LAB_PORT% ^
|
||||
--ximilar-key "%XIMILAR_API_KEY%"
|
||||
)
|
||||
@@ -1,2 +0,0 @@
|
||||
# Phase 0: Card images downloaded here by scripts/download_card_images.py
|
||||
# ~1.5-2GB at normal size
|
||||
@@ -0,0 +1,495 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Commander Battlefield</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Inter:wght@400;500;600&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--felt: #2a2a2a;
|
||||
--felt-light: #333333;
|
||||
--felt-dark: #1e1e1e;
|
||||
--felt-line: rgba(255,255,255,0.08);
|
||||
--felt-line-strong: rgba(255,255,255,0.15);
|
||||
|
||||
--p1: #c0392b; --p1-soft: rgba(192,57,43,0.15); --p1-glow: rgba(192,57,43,0.35); --p1-text: #e74c3c;
|
||||
--p2: #2980b9; --p2-soft: rgba(41,128,185,0.15); --p2-glow: rgba(41,128,185,0.35); --p2-text: #3498db;
|
||||
--p3: #27ae60; --p3-soft: rgba(39,174,96,0.15); --p3-glow: rgba(39,174,96,0.35); --p3-text: #2ecc71;
|
||||
--p4: #d4930d; --p4-soft: rgba(212,147,13,0.15); --p4-glow: rgba(212,147,13,0.35); --p4-text: #f1c40f;
|
||||
|
||||
--card-w: 63px;
|
||||
--card-h: 88px;
|
||||
--zone-radius: 8px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%; height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Inter', sans-serif;
|
||||
background: var(--felt-dark);
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.table-surface {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: radial-gradient(ellipse at 50% 50%, var(--felt-light) 0%, var(--felt) 50%, var(--felt-dark) 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-surface::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.08'/%3E%3C/svg%3E");
|
||||
background-size: 200px 200px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.table-surface::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.45) 100%);
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.battlefield {
|
||||
position: absolute;
|
||||
inset: 10px;
|
||||
z-index: 5;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-rows: 1fr 1fr;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.divider-h, .divider-v {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
.divider-h {
|
||||
top: 50%; left: 10px; right: 10px;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: var(--felt-line-strong);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.04);
|
||||
}
|
||||
.divider-v {
|
||||
left: 50%; top: 10px; bottom: 10px;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: var(--felt-line-strong);
|
||||
box-shadow: 0 0 12px rgba(255,255,255,0.04);
|
||||
}
|
||||
|
||||
.center-emblem {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 15;
|
||||
width: 76px; height: 76px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, rgba(55,55,55,0.95), rgba(25,25,25,0.98));
|
||||
border: 2px solid rgba(255,255,255,0.08);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 30px rgba(0,0,0,0.6), inset 0 0 20px rgba(0,0,0,0.3);
|
||||
}
|
||||
.center-emblem svg { width: 40px; height: 40px; }
|
||||
|
||||
.player-area {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.player-area.p1 { grid-area: 1 / 1 / 2 / 2; }
|
||||
.player-area.p2 { grid-area: 1 / 2 / 2 / 3; }
|
||||
.player-area.p3 { grid-area: 2 / 1 / 3 / 2; }
|
||||
.player-area.p4 { grid-area: 2 / 2 / 3 / 3; }
|
||||
|
||||
.p1, .p2 { justify-content: flex-start; }
|
||||
.p3, .p4 { justify-content: flex-end; }
|
||||
|
||||
.player-label {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
letter-spacing: 2px;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 10px;
|
||||
border-radius: 4px;
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.p1 .player-label { top: 8px; left: 8px; color: var(--p1-text); background: var(--p1-soft); }
|
||||
.p2 .player-label { top: 8px; right: 8px; color: var(--p2-text); background: var(--p2-soft); }
|
||||
.p3 .player-label { bottom: 8px; left: 8px; color: var(--p3-text); background: var(--p3-soft); }
|
||||
.p4 .player-label { bottom: 8px; right: 8px; color: var(--p4-text); background: var(--p4-soft); }
|
||||
|
||||
.life-counter {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.life-counter button {
|
||||
width: 22px; height: 22px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
background: rgba(255,255,255,0.04);
|
||||
color: rgba(255,255,255,0.45);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s;
|
||||
font-family: 'Inter', sans-serif;
|
||||
line-height: 1;
|
||||
}
|
||||
.life-counter button:hover {
|
||||
background: rgba(255,255,255,0.12);
|
||||
color: rgba(255,255,255,0.8);
|
||||
}
|
||||
.p1 .life-counter { top: 8px; right: 8px; color: var(--p1-text); }
|
||||
.p2 .life-counter { top: 8px; left: 8px; color: var(--p2-text); }
|
||||
.p3 .life-counter { bottom: 8px; right: 8px; color: var(--p3-text); }
|
||||
.p4 .life-counter { bottom: 8px; left: 8px; color: var(--p4-text); }
|
||||
|
||||
.zone-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.p1 .zone-row, .p3 .zone-row { flex-direction: row; }
|
||||
.p2 .zone-row, .p4 .zone-row { flex-direction: row-reverse; }
|
||||
|
||||
.commander-row {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.p1 .commander-row, .p3 .commander-row { justify-content: flex-start; }
|
||||
.p2 .commander-row, .p4 .commander-row { justify-content: flex-end; }
|
||||
|
||||
.battle-zone {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
border-radius: var(--zone-radius);
|
||||
min-height: 50px;
|
||||
}
|
||||
.battle-zone-inner {
|
||||
position: absolute;
|
||||
inset: 4px;
|
||||
border-radius: var(--zone-radius);
|
||||
border: 1px dashed;
|
||||
opacity: 0.12;
|
||||
pointer-events: none;
|
||||
}
|
||||
.p1 .battle-zone-inner { border-color: var(--p1); }
|
||||
.p2 .battle-zone-inner { border-color: var(--p2); }
|
||||
.p3 .battle-zone-inner { border-color: var(--p3); }
|
||||
.p4 .battle-zone-inner { border-color: var(--p4); }
|
||||
|
||||
.battle-zone-label {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 11px;
|
||||
letter-spacing: 4px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.06;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.zone {
|
||||
position: relative;
|
||||
border-radius: var(--zone-radius);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: default;
|
||||
transition: all 0.25s ease;
|
||||
border: 1px solid rgba(255,255,255,0.05);
|
||||
background: rgba(0,0,0,0.18);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.zone:hover {
|
||||
border-color: rgba(255,255,255,0.12);
|
||||
background: rgba(0,0,0,0.28);
|
||||
}
|
||||
|
||||
.zone-icon {
|
||||
font-size: 16px;
|
||||
opacity: 0.55;
|
||||
margin-bottom: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zone-name {
|
||||
font-size: 7.5px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1.2px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.zone.library, .zone.exile, .zone.graveyard {
|
||||
width: var(--card-w);
|
||||
height: var(--card-h);
|
||||
}
|
||||
.zone.hand {
|
||||
flex: 1;
|
||||
height: var(--card-h);
|
||||
min-width: 80px;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
}
|
||||
.zone.hand .zone-icon { margin-bottom: 0; margin-right: 3px; }
|
||||
.zone.commander {
|
||||
width: calc(var(--card-w) + 14px);
|
||||
height: calc(var(--card-h) + 6px);
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.p1 .zone { border-color: rgba(192,57,43,0.12); }
|
||||
.p1 .zone:hover { border-color: rgba(192,57,43,0.3); box-shadow: 0 0 12px var(--p1-soft); }
|
||||
.p1 .zone.commander { border-color: rgba(192,57,43,0.25); background: var(--p1-soft); }
|
||||
.p1 .zone-icon { color: var(--p1-text); }
|
||||
|
||||
.p2 .zone { border-color: rgba(41,128,185,0.12); }
|
||||
.p2 .zone:hover { border-color: rgba(41,128,185,0.3); box-shadow: 0 0 12px var(--p2-soft); }
|
||||
.p2 .zone.commander { border-color: rgba(41,128,185,0.25); background: var(--p2-soft); }
|
||||
.p2 .zone-icon { color: var(--p2-text); }
|
||||
|
||||
.p3 .zone { border-color: rgba(39,174,96,0.12); }
|
||||
.p3 .zone:hover { border-color: rgba(39,174,96,0.3); box-shadow: 0 0 12px var(--p3-soft); }
|
||||
.p3 .zone.commander { border-color: rgba(39,174,96,0.25); background: var(--p3-soft); }
|
||||
.p3 .zone-icon { color: var(--p3-text); }
|
||||
|
||||
.p4 .zone { border-color: rgba(212,147,13,0.12); }
|
||||
.p4 .zone:hover { border-color: rgba(212,147,13,0.3); box-shadow: 0 0 12px var(--p4-soft); }
|
||||
.p4 .zone.commander { border-color: rgba(212,147,13,0.25); background: var(--p4-soft); }
|
||||
.p4 .zone-icon { color: var(--p4-text); }
|
||||
|
||||
.zone[data-tooltip]:hover::after {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0,0,0,0.88);
|
||||
color: #ccc;
|
||||
font-size: 10px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
z-index: 50;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card-count {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
right: 4px;
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,0.25);
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 1px 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.title-bar {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
z-index: 30;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.title-bar h1 {
|
||||
font-family: 'Cinzel', serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 5px;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255,255,255,0.15);
|
||||
}
|
||||
|
||||
.corner-accent {
|
||||
position: absolute;
|
||||
width: 24px; height: 24px;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
opacity: 0.12;
|
||||
}
|
||||
.corner-accent.tl { top: 6px; left: 6px; border-top: 2px solid var(--p1); border-left: 2px solid var(--p1); }
|
||||
.corner-accent.tr { top: 6px; right: 6px; border-top: 2px solid var(--p2); border-right: 2px solid var(--p2); }
|
||||
.corner-accent.bl { bottom: 6px; left: 6px; border-bottom: 2px solid var(--p3); border-left: 2px solid var(--p3); }
|
||||
.corner-accent.br { bottom: 6px; right: 6px; border-bottom: 2px solid var(--p4); border-right: 2px solid var(--p4); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
:root { --card-w: 42px; --card-h: 58px; }
|
||||
.player-label { font-size: 7px; letter-spacing: 1px; padding: 2px 6px; }
|
||||
.life-counter { font-size: 15px; }
|
||||
.life-counter button { width: 18px; height: 18px; font-size: 11px; }
|
||||
.zone-name { font-size: 6px; }
|
||||
.zone-icon { font-size: 12px; }
|
||||
.center-emblem { width: 46px; height: 46px; }
|
||||
.center-emblem svg { width: 26px; height: 26px; }
|
||||
.battle-zone-label { font-size: 8px; letter-spacing: 2px; }
|
||||
.zone-row { gap: 3px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="table-surface">
|
||||
<div class="title-bar"><h1>Commander Battlefield</h1></div>
|
||||
<div class="corner-accent tl"></div>
|
||||
<div class="corner-accent tr"></div>
|
||||
<div class="corner-accent bl"></div>
|
||||
<div class="corner-accent br"></div>
|
||||
<div class="divider-h"></div>
|
||||
<div class="divider-v"></div>
|
||||
|
||||
<div class="center-emblem">
|
||||
<svg viewBox="0 0 100 100" fill="none">
|
||||
<circle cx="50" cy="50" r="42" stroke="rgba(255,255,255,0.2)" stroke-width="1.5"/>
|
||||
<circle cx="50" cy="50" r="28" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
|
||||
<polygon points="50,15 58.5,37 82,37 63,51 69,74 50,60 31,74 37,51 18,37 41.5,37"
|
||||
fill="rgba(255,255,255,0.1)" stroke="rgba(255,255,255,0.2)" stroke-width="0.8"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="battlefield">
|
||||
|
||||
<!-- PLAYER 1: Top-Left (Crimson) -->
|
||||
<div class="player-area p1">
|
||||
<span class="player-label">Player 1</span>
|
||||
<div class="life-counter">
|
||||
<button onclick="changeLife(1,-1)" aria-label="Decrease life">−</button>
|
||||
<span id="life1">40</span>
|
||||
<button onclick="changeLife(1,1)" aria-label="Increase life">+</button>
|
||||
</div>
|
||||
<div class="zone-row">
|
||||
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
|
||||
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
|
||||
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
|
||||
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
|
||||
</div>
|
||||
<div class="commander-row">
|
||||
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
|
||||
</div>
|
||||
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 2: Top-Right (Sapphire) -->
|
||||
<div class="player-area p2">
|
||||
<span class="player-label">Player 2</span>
|
||||
<div class="life-counter">
|
||||
<button onclick="changeLife(2,-1)" aria-label="Decrease life">−</button>
|
||||
<span id="life2">40</span>
|
||||
<button onclick="changeLife(2,1)" aria-label="Increase life">+</button>
|
||||
</div>
|
||||
<div class="zone-row">
|
||||
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
|
||||
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
|
||||
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
|
||||
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
|
||||
</div>
|
||||
<div class="commander-row">
|
||||
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
|
||||
</div>
|
||||
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 3: Bottom-Left (Emerald) -->
|
||||
<div class="player-area p3">
|
||||
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
|
||||
<div class="commander-row">
|
||||
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
|
||||
</div>
|
||||
<div class="zone-row">
|
||||
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
|
||||
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
|
||||
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
|
||||
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
|
||||
</div>
|
||||
<span class="player-label">Player 3</span>
|
||||
<div class="life-counter">
|
||||
<button onclick="changeLife(3,-1)" aria-label="Decrease life">−</button>
|
||||
<span id="life3">40</span>
|
||||
<button onclick="changeLife(3,1)" aria-label="Increase life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PLAYER 4: Bottom-Right (Gold) -->
|
||||
<div class="player-area p4">
|
||||
<div class="battle-zone"><div class="battle-zone-inner"></div><span class="battle-zone-label">Battlefield</span></div>
|
||||
<div class="commander-row">
|
||||
<div class="zone commander" data-tooltip="Commander Zone"><span class="zone-icon">👑</span><span class="zone-name">Commander</span></div>
|
||||
</div>
|
||||
<div class="zone-row">
|
||||
<div class="zone library" data-tooltip="Library"><span class="card-count">99</span><span class="zone-icon">📚</span><span class="zone-name">Library</span></div>
|
||||
<div class="zone exile" data-tooltip="Exile"><span class="zone-icon">✦</span><span class="zone-name">Exile</span></div>
|
||||
<div class="zone graveyard" data-tooltip="Graveyard"><span class="zone-icon">💀</span><span class="zone-name">Grave</span></div>
|
||||
<div class="zone hand" data-tooltip="Hand"><span class="zone-icon">🃏</span><span class="zone-name">Hand</span></div>
|
||||
</div>
|
||||
<span class="player-label">Player 4</span>
|
||||
<div class="life-counter">
|
||||
<button onclick="changeLife(4,-1)" aria-label="Decrease life">−</button>
|
||||
<span id="life4">40</span>
|
||||
<button onclick="changeLife(4,1)" aria-label="Increase life">+</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const lives = { 1: 40, 2: 40, 3: 40, 4: 40 };
|
||||
function changeLife(p, d) {
|
||||
lives[p] = Math.max(0, lives[p] + d);
|
||||
const el = document.getElementById('life' + p);
|
||||
el.textContent = lives[p];
|
||||
el.style.transform = 'scale(1.25)';
|
||||
el.style.transition = 'transform 0.15s ease';
|
||||
setTimeout(() => { el.style.transform = 'scale(1)'; }, 150);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+18
-2
@@ -835,8 +835,8 @@ const AiLab = (() => {
|
||||
|
||||
// Commander art from Scryfall — use imageUrl if backend provided it,
|
||||
// otherwise build a fuzzy lookup URL from the commander name
|
||||
const artUrl = p.imageUrl
|
||||
|| `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
|
||||
const scryfallFallback = `https://api.scryfall.com/cards/named?fuzzy=${encodeURIComponent(p.commander)}&format=image&version=art_crop`;
|
||||
const artUrl = p.imageUrl || scryfallFallback;
|
||||
|
||||
return `
|
||||
<div class="lab-precon-card ${installClass}" onclick="AiLab.installPrecon('${escapeAttr(p.fileName)}')">
|
||||
@@ -854,6 +854,22 @@ const AiLab = (() => {
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
loadPreconArt(gridEl);
|
||||
}
|
||||
|
||||
async function loadPreconArt(gridEl) {
|
||||
const imgs = gridEl.querySelectorAll('img.precon-card-art[data-commander]');
|
||||
for (const img of imgs) {
|
||||
const commander = img.dataset.commander;
|
||||
if (!commander) continue;
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/card-image/${encodeURIComponent(commander)}`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
if (data.art_url) { img.src = data.art_url; img.style.display = ''; }
|
||||
}
|
||||
} catch (_) { /* keep Scryfall fallback */ }
|
||||
}
|
||||
}
|
||||
|
||||
async function installPrecon(fileName) {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Phase 4: Unity TCGEngine project lives here
|
||||
# See issue #171
|
||||
Reference in new issue
Block a user