Compare commits

..
Author SHA1 Message Date
KoalaTrapLord d31b8467d0 New HTML battlefield to test features before Unity 2026-04-03 04:39:01 -04:00
KoalaTrapLord 7a40b5deb8 fix: enrich type_line/oracle from Forge cache in .dck fallback loader
_load_deck_cards_from_dck was hardcoding type_line="" for every card,
causing Card.is_land() to return False for all lands when a deck is
loaded via the .dck file path (i.e. not in the SQLite DB). This meant
the engine never recognised lands as lands, breaking mana production.

Fix: call lookup_forge_card() per card and populate type_line,
oracle_text, cmc, mana_cost, power, toughness, and keywords from the
Forge cardsfolder cache. The cache is lazy-loaded and module-level so
there is no per-game overhead after the first deck load.
2026-04-03 04:01:59 -04:00
KoalaTrapLord af8970f3a8 fix: _load_deck_cards_by_name falls back to .dck file when deck not in DB
Precon decks (Elven_Empire, Blood_Rites, etc.) are stored as .dck files
on disk but not inserted into the SQLite decks/deck_cards tables.
The DeepSeek batch engine calls _load_deck_cards_by_name() which
previously returned [] for any deck not in the DB, causing
'No decks could be loaded.' errors.

Adds _find_dck_file() and _load_deck_cards_from_dck() helpers.
_find_dck_file searches CFG.precon_dir, CFG.forge_decks_dir, and
FORGE_DIR/res/decks/commander with exact + fuzzy matching.
_load_deck_cards_by_name now tries DB first, then .dck fallback.
2026-04-02 22:59:01 -04:00
KoalaTrapLord 53fd1c9257 fix: wire RESULTS_DIR and FORGE_DIR env vars into CFG at startup 2026-04-02 14:24:28 -04:00
KoalaTrapLord 6ead75f96b fix: set FORGE_WORKING_DIR to forge-gui (requires res/ folder for sim to start) 2026-04-02 02:33:14 -04:00
KoalaTrapLord 535bdd7c70 fix: make Forge deck paths fully portable using %~dp0 and auto-generate forge.profile.properties 2026-04-02 00:59:10 -04:00
KoalaTrapLord d98e6331e9 fix: point all deck lookups to precon-decks directory
- start-lab.bat: Set FORGE_DECKS_DIR to D:\ForgeCommander\commander-ai-lab\precon-decks
- BatchRunner.java: resolveForgeDecksPath() now checks PRECON_DECKS_DIR env var
  first, then falls back to the hardcoded precon-decks path, before trying
  %APPDATA%\Forge\decks\commander. Also pass full absolute path to Forge -d flag.
- .env.example: document PRECON_DECKS_DIR variable
2026-04-01 23:10:32 -04:00
KoalaTrapLord 2b3196267f fix: raise default clock from 300ms to 5000ms to prevent turn-1 draw games 2026-04-01 22:56:29 -04:00
rgriffith 433e8bc4d7 feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 22:09:13 -04:00
KoalaTrapLord d74dd0a6bf feat: wire precon card art through local /card-image/ API with Scryfall fallback (full file) 2026-04-01 21:45:15 -04:00
KoalaTrapLord 8037802dff feat: wire precon card art through local /card-image/ API with Scryfall fallback 2026-04-01 21:45:01 -04:00
KoalaTrapLord ca6c23a881 fix: wrap shutil.copy2 in try/except for install and install-batch endpoints 2026-04-01 21:32:19 -04:00
KoalaTrapLord a81772c68a fix: set cwd=CFG.forge_dir on Popen so Forge finds res/ (#184) 2026-04-01 19:41:52 -04:00
KoalaTrapLord 99ca498b88 docs: add Unity TCGEngine Commander board implementation plan 2026-04-01 14:59:46 -04:00
KoalaTrapLord 395ad06cdd fix: resolve deck names to full .dck paths in build_java_command (#183) 2026-04-01 14:57:57 -04:00
KoalaTrapLord e4a6ce644d fix(#176): draw-game life capture, DrawGameAnalyzer, clock 6000→3000 (#181)
Captures life-at-timeout snapshots for every draw game via [DRAW] [PARSE-SUMMARY] log lines. Adds services/draw_game_analyzer.py with DrawGameSnapshot + summarize_draw_games(). Wires drawGames summary into GET /api/lab/result response. Lowers batch clock default from 6000 to 3000 (30s per-game). Closes #176.
2026-04-01 14:40:29 -04:00
KoalaTrapLord 5411fd6bf6 fix(#175): alternate win condition parser & winType tagging (#179)
Adds win_condition_parser.py, GameResult.win_type field, and winType stamping on both the Java/Forge and DeepSeek batch paths. Closes #175.
2026-04-01 14:28:10 -04:00
42 changed files with 33423 additions and 1273 deletions

No files matched your search

+17 -31
View File
@@ -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
View File
@@ -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
View File
@@ -1 +0,0 @@
# Phase 2: Dual-Brain AI Decision Engine
-5
View File
@@ -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
-15
View File
@@ -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",
]
-12
View File
@@ -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
-167
View File
@@ -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
View File
@@ -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
View File
@@ -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
-2
View File
@@ -1,2 +0,0 @@
# Runtime data directory
# card_image_index.db, decision_logs/, game_results.jsonl, training_sets/
+785
View File
@@ -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. |
+25492
View File
File diff suppressed because it is too large. Load diff
+634
View File
@@ -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>&#x21A9;</button>
<button class="btn-util" id="btn-redo" onclick="redo()" title="Redo (Ctrl+Y)" disabled>&#x21AA;</button>
<button class="btn-util" id="btn-triggers" onclick="toggleTriggerStack()" title="Trigger Stack">&#x2747;</button>
<button class="btn-util" onclick="openDiceModal()" title="Dice &amp; Coins (G)">&#x1F3B2;</button>
<button class="btn-util" onclick="checkStateBasedActions()" title="Check State-Based Actions">SBA</button>
<button class="btn-util" onclick="toggleSettingsPanel()" title="Settings">&#x2699;</button>
<button class="btn-util" id="btn-search-toggle" onclick="document.getElementById('card-search-sidebar').classList.toggle('collapsed')" title="Card Search (S)">&#x1F50D;</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">&#x2212;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x2212;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x1F451;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x2212;</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">&#x1F451;</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">&#x1F4DA;</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">&#x2B21;</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">&#x2620;</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">&#x1F0CF;</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">&#x2212;</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">&#x2715;</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">&#x2039;</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()">&#x2715;</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 &amp; Coins</span>
<button class="btn-close" onclick="closeDiceModal()">&#x2715;</button>
</div>
<div class="dice-result-display" id="dice-result-display">&#x2014;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x2715;</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()">&#x1F3B2; 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()">&#x2715;</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('&#x1F451; <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
View File
@@ -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
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
unzip mtg-commander-lan.zip
cd mtg-commander-lan
npm install
node server.js
+6
View File
@@ -0,0 +1,6 @@
{
"name": "MTG Commander (HTML)",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
-4
View File
@@ -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
-94
View File
@@ -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
View File
@@ -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
View File
@@ -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}
-135
View File
@@ -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()
-181
View File
@@ -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()
-4
View File
@@ -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
View File
@@ -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")
+152
View File
@@ -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
View File
@@ -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:
+6
View File
@@ -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
View File
@@ -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%"
)
-2
View File
@@ -1,2 +0,0 @@
# Phase 0: Card images downloaded here by scripts/download_card_images.py
# ~1.5-2GB at normal size
+3028
View File
File diff suppressed because it is too large. Load diff
+495
View File
@@ -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>
+2068
View File
File diff suppressed because it is too large. Load diff
+18 -2
View File
@@ -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) {
-2
View File
@@ -1,2 +0,0 @@
# Phase 4: Unity TCGEngine project lives here
# See issue #171