Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa43fe3ecd |
No files matched your search
@@ -1,145 +0,0 @@
|
||||
# Commander-AI-Lab GUI + LLM Opponents Roadmap
|
||||
|
||||
## Project Summary
|
||||
|
||||
Build a playable Magic: The Gathering Commander interface where human players face AI opponents powered by a local LLM. The existing Python rules engine handles game legality and state management, while the LLM acts as the decision-making and narration brain for each AI opponent. A Pygame (or web-based) GUI renders the battlefield, zones, and card interactions. Each AI opponent gets a unique personality prompt to simulate different playstyles (aggro, control, combo, politics).
|
||||
|
||||
**Local LLM:** GPT-OSS 20B (Q4_K_M) via Ollama or LM Studio
|
||||
|
||||
---
|
||||
|
||||
## GUI Strategy: Two-Phase Frontend Plan
|
||||
|
||||
### Phase A (Prototype) — Pygame
|
||||
Use **Pygame** for Phases 1–3 of development. Pure Python means the rules engine and AI opponent code plug in directly with zero bridging. Fast to iterate — a working battlefield renderer can be built in a day. Handles game loop, input events, and card rendering naturally. Ideal for testing AI decisions visually before investing in a polished UI.
|
||||
|
||||
### Phase B (Production) — Flask/FastAPI + Web Frontend
|
||||
Once the core AI gameplay is working, migrate the UI to a **Flask or FastAPI backend + HTML/JS frontend** (vanilla JS or React). Benefits:
|
||||
- Far more polished look for a Commander game
|
||||
- Card art from Scryfall API renders natively in browser
|
||||
- Easier to add multiplayer later via WebSockets
|
||||
- Python backend stays pure — frontend calls it via REST API
|
||||
- No Python/JS bridge complexity; clean separation of concerns
|
||||
|
||||
### GUI Framework Comparison
|
||||
|
||||
| Option | Language | Curve | Best For | Weakness |
|
||||
|---|---|---|---|---|
|
||||
| **Pygame** ✅ Phase A | Python | Low | Quick prototype, game loops | Dated UI, manual widgets |
|
||||
| **Flask + HTML/JS** ✅ Phase B | Python + JS | Medium | Polished UI, browser-based | JS context switch |
|
||||
| PyQt6 / PySide6 | Python | Medium | Desktop app widgets | Verbose, overkill for a game |
|
||||
| Godot | GDScript | Medium | Full game engine, animations | Separate codebase from engine |
|
||||
| Electron + Python | JS + Python | High | Desktop with web UI | Heavy, complex bridge |
|
||||
| Tkinter | Python | Low | Simple UI | Very dated look |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Game State (Python dict/dataclass)
|
||||
↓
|
||||
Legal Moves Generator (rules engine)
|
||||
↓
|
||||
LLM Prompt: "Given this state and these legal moves, what do you do?"
|
||||
↓ (GPT-OSS 20B Q4_K_M)
|
||||
LLM Response → parsed action
|
||||
↓
|
||||
Rules Engine executes + validates
|
||||
↓
|
||||
GUI updates board state (Pygame → Web)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task List
|
||||
|
||||
### Phase 1 — Game State Serialization
|
||||
- [ ] Define a compressed game state schema (Python dataclass or dict) covering: hand, battlefield, graveyard, exile, stack, life totals, commander zone, mana pool
|
||||
- [ ] Write a `state_to_prompt()` function that converts game state to a compact LLM-readable string
|
||||
- [ ] Write a `legal_moves_to_prompt()` function that lists valid actions in numbered format for LLM selection
|
||||
- [ ] Add a state snapshot logger for debugging AI decisions
|
||||
|
||||
### Phase 2 — LLM AI Opponent Integration
|
||||
- [ ] Load GPT-OSS 20B (Q4_K_M) in Ollama or LM Studio; verify inference speed on RTX 5070 Ti
|
||||
- [ ] Benchmark VRAM usage for GPT-OSS 20B Q4_K_M (expected ~12–14 GB VRAM at Q4_K_M quantization)
|
||||
- [ ] Build `AIOpponent` class with fields: name, personality_prompt, model_endpoint, deck
|
||||
- [ ] Implement `decide_action(game_state, legal_moves)` method — sends prompt to GPT-OSS 20B, parses LLM response, returns validated action index
|
||||
- [ ] Add fallback logic: if LLM returns invalid/unparseable action, default to highest-priority heuristic move
|
||||
- [ ] Write personality prompt templates (Aggro Timmy, Control Spike, Combo Johnny, Political Negotiator)
|
||||
- [ ] Add narration method: `narrate_play(action)` — GPT-OSS 20B generates in-character flavor text for each play
|
||||
- [ ] Tune system prompt length to stay within GPT-OSS 20B context window limits
|
||||
|
||||
### Phase 3 — Multi-Player Turn Management
|
||||
- [ ] Implement turn queue: Player → AI1 → AI2 → AI3 → repeat
|
||||
- [ ] Run AI decisions asynchronously (threading or asyncio) so GUI stays responsive during LLM inference
|
||||
- [ ] Add priority-passing logic for instants/triggers during other players' turns
|
||||
- [ ] Implement basic AI threat assessment: each AI scores opponents by board state danger level
|
||||
|
||||
### Phase 4 — Pygame Prototype GUI
|
||||
- [ ] Design zone layout: hand, battlefield (creature/non-creature split), graveyard, exile, command zone, stack
|
||||
- [ ] Build card rendering: card art placeholder, name, P/T or loyalty, tapped/untapped state
|
||||
- [ ] Add click-to-play card interaction for human player
|
||||
- [ ] Display AI narration/flavor text in a side panel
|
||||
- [ ] Add life total trackers and commander damage matrix (4-player)
|
||||
- [ ] Build phase/step indicator (Main 1, Combat, Main 2, etc.)
|
||||
- [ ] Add end-of-game screen with win condition display
|
||||
|
||||
### Phase 5 — Web Frontend Migration *(Flask/FastAPI + HTML/JS)*
|
||||
- [ ] Set up Flask or FastAPI backend exposing game state as REST API
|
||||
- [ ] Build HTML/JS board layout mirroring Pygame zone design
|
||||
- [ ] Integrate Scryfall API for card art rendering
|
||||
- [ ] Add WebSocket support for real-time game state updates
|
||||
- [ ] Add drag-and-drop card interaction
|
||||
- [ ] Port AI narration panel to web chat-style UI
|
||||
- [ ] Test full game loop end-to-end on web frontend
|
||||
|
||||
### Phase 6 — Politics & Table Talk *(Stretch Goal)*
|
||||
- [ ] Implement inter-AI negotiation: before attacking, AI queries GPT-OSS 20B "should I propose a deal?"
|
||||
- [ ] Build deal proposal UI: AI proposes, human can accept/decline, other AIs respond
|
||||
- [ ] Add threat-based targeting memory: AIs remember who attacked them last turn
|
||||
|
||||
### Phase 7 — Testing & Tuning
|
||||
- [ ] Run 10 full simulated games (4 AI vs AI) and log decision quality
|
||||
- [ ] Benchmark GPT-OSS 20B Q4_K_M inference time per decision on RTX 5070 Ti
|
||||
- [ ] Tune context window compression to stay within model token limits
|
||||
- [ ] Profile Pygame and web render loops for stability during AI thinking phases
|
||||
- [ ] Add unit tests for `state_to_prompt()` and `decide_action()` parsing
|
||||
|
||||
### Phase 8 — Multiplayer (Human vs Human + AI)
|
||||
- [ ] **Lobby system** — create/join game rooms with configurable seat count (2–4 players); assign each seat as Human or AI
|
||||
- [ ] **WebSocket game server** — upgrade Flask/FastAPI backend to handle persistent connections for real-time state sync across multiple browser clients
|
||||
- [ ] **Player session management** — unique session tokens per human player; reconnect support if connection drops mid-game
|
||||
- [ ] **Private hand views** — each human player sees only their own hand; other hands shown as face-down card backs
|
||||
- [ ] **Turn notification system** — alert active player when it's their turn (browser notification or in-page indicator)
|
||||
- [ ] **Action broadcast** — all players see each action played in real-time (card played, attack declared, ability activated) via WebSocket push
|
||||
- [ ] **Mixed lobby support** — allow any combination of human and AI seats (e.g., 2 humans + 2 AIs, or 1 human + 3 AIs)
|
||||
- [ ] **Spectator mode** — allow observers to watch a game without participating; full board visible, no hand info
|
||||
- [ ] **Local LAN play** — host game server on local network so friends can connect via browser on the same Wi-Fi (no internet required)
|
||||
- [ ] **Optional: Remote play** — expose server via ngrok or Tailscale for play over the internet without full deployment
|
||||
- [ ] **Multiplayer AI pacing** — add configurable AI decision delay (e.g., 1–3 sec) so human players can follow AI turns without them resolving instantly
|
||||
- [ ] **Chat panel** — in-game text chat between human players; AI opponents can optionally post narration lines into the same channel
|
||||
- [ ] **End-of-game summary** — show full game recap (turns played, damage dealt, commanders cast count) visible to all players after win condition met
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Choice | Notes |
|
||||
|---|---|---|
|
||||
| GUI (Prototype) | **Pygame** | Pure Python, fast iteration |
|
||||
| GUI (Production) | **Flask/FastAPI + HTML/JS** | Polished, browser-based |
|
||||
| Multiplayer | **WebSockets (Flask-SocketIO)** | Real-time state sync across clients |
|
||||
| LLM Backend | Ollama or LM Studio | Serves GPT-OSS 20B locally |
|
||||
| Model | **GPT-OSS 20B (Q4_K_M)** | ~12–14 GB VRAM, strong reasoning |
|
||||
| Game State | Python dataclass | Structured, easy to serialize |
|
||||
| Async | asyncio / threading | Keep GUI responsive during inference |
|
||||
| Card Data | Scryfall API | Free MTG card data + art |
|
||||
| LAN Hosting | Local network / ngrok | No deployment needed for local play |
|
||||
|
||||
### GPT-OSS 20B Q4_K_M Notes
|
||||
- Q4_K_M quantization balances quality and speed well for a 20B model
|
||||
- Expected VRAM: ~12–14 GB on RTX 5070 Ti (16 GB VRAM) — fits comfortably
|
||||
- Stronger reasoning than 7B models — better for multi-step MTG decision making
|
||||
- Slower inference than smaller models — async handling is essential so GUI doesn't block
|
||||
- System prompt + game state should be kept under ~3000 tokens to maintain fast response times
|
||||
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
|
||||
Real implementation will call the Archidekt API.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.archidekt")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -30,5 +26,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
# 1. Search Archidekt for popular decks with this commander
|
||||
# 2. Fetch the top 3-5 decklists
|
||||
# 3. Normalize card names and return as TemplateDeck
|
||||
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
|
||||
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
|
||||
return []
|
||||
@@ -7,10 +7,6 @@ Currently returns a hardcoded sample for end-to-end wiring.
|
||||
Real implementation will parse EDHREC commander pages.
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.edhrec")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -31,5 +27,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
# 2. Fetch the "Average Deck" or "Top Cards" list
|
||||
# 3. Parse card names and inclusion percentages
|
||||
# 4. Return as a single TemplateDeck with weighted cards
|
||||
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
|
||||
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
|
||||
return []
|
||||
@@ -6,10 +6,6 @@ Fetches template decklists from Moxfield for a given commander.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.moxfield")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
Fetch template decks from Moxfield for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
"""
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
@@ -6,10 +6,6 @@ Fetches template decklists from MTGGoldfish for a given commander.
|
||||
Currently returns empty (experimental — not yet implemented).
|
||||
"""
|
||||
from .models import TemplateDeck, TemplateDeckCard
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.deckgen.mtggoldfish")
|
||||
|
||||
|
||||
|
||||
def fetch_template_decks(commander_name: str, color_identity: list, config: dict = None) -> list:
|
||||
@@ -17,5 +13,5 @@ def fetch_template_decks(commander_name: str, color_identity: list, config: dict
|
||||
Fetch template decks from MTGGoldfish for the given commander.
|
||||
Experimental — not yet implemented.
|
||||
"""
|
||||
log.info(f" Stub: Not yet implemented for '{commander_name}'")
|
||||
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
|
||||
return []
|
||||
+119
-387
@@ -19,8 +19,6 @@ import asyncio
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
@@ -64,61 +62,6 @@ class Config:
|
||||
CFG = Config()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Logging Setup
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
_LOG_FORMAT = "%(asctime)s %(levelname)-5s [%(name)s] %(message)s"
|
||||
_LOG_DATE_FMT = "%Y-%m-%d %H:%M:%S"
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
_LOG_MAX_BYTES = int(os.environ.get("CAL_LOG_MAX_MB", "25")) * 1024 * 1024
|
||||
_LOG_BACKUP_COUNT = int(os.environ.get("CAL_LOG_BACKUP_COUNT", "5"))
|
||||
|
||||
|
||||
def setup_logging(level: int = logging.INFO) -> None:
|
||||
"""Configure the root 'commander_ai_lab' logger with console + rotating file."""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
root_logger = logging.getLogger("commander_ai_lab")
|
||||
root_logger.setLevel(level)
|
||||
|
||||
# Avoid duplicate handlers if called again
|
||||
if root_logger.handlers:
|
||||
return
|
||||
|
||||
formatter = logging.Formatter(_LOG_FORMAT, datefmt=_LOG_DATE_FMT)
|
||||
|
||||
# Console handler — matches previous print() behaviour
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(level)
|
||||
console.setFormatter(formatter)
|
||||
root_logger.addHandler(console)
|
||||
|
||||
# Rotating file handler — 25 MB × 5 backups = 125 MB max
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "commander-ai-lab.log",
|
||||
maxBytes=_LOG_MAX_BYTES,
|
||||
backupCount=_LOG_BACKUP_COUNT,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(level)
|
||||
file_handler.setFormatter(formatter)
|
||||
root_logger.addHandler(file_handler)
|
||||
|
||||
|
||||
# Named loggers — import these where needed
|
||||
log = logging.getLogger("commander_ai_lab.api") # API endpoints, server lifecycle
|
||||
log_batch = logging.getLogger("commander_ai_lab.batch") # Batch sim (Java + DeepSeek)
|
||||
log_sim = logging.getLogger("commander_ai_lab.sim") # DeepSeek game simulation
|
||||
log_coach = logging.getLogger("commander_ai_lab.coach") # Coach + report generation
|
||||
log_deckgen = logging.getLogger("commander_ai_lab.deckgen") # Deck generation (all versions)
|
||||
log_collect = logging.getLogger("commander_ai_lab.collection") # Collection management
|
||||
log_scan = logging.getLogger("commander_ai_lab.scanner") # Card scanner
|
||||
log_ml = logging.getLogger("commander_ai_lab.ml") # ML training + inference
|
||||
log_cache = logging.getLogger("commander_ai_lab.cache") # Scryfall cache
|
||||
log_pplx = logging.getLogger("commander_ai_lab.pplx") # Perplexity API
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# App Setup
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -426,10 +369,10 @@ async def fetch_meta_deck(req: MetaFetchRequest):
|
||||
@app.post("/api/lab/start", response_model=StartResponse)
|
||||
async def start_batch(req: StartRequest, background_tasks: BackgroundTasks):
|
||||
"""Start a new batch simulation run."""
|
||||
if len(req.decks) < 2 or len(req.decks) > 4:
|
||||
raise HTTPException(400, "2-4 decks required")
|
||||
if len(req.decks) != 3:
|
||||
raise HTTPException(400, "Exactly 3 decks required")
|
||||
if not all(req.decks):
|
||||
raise HTTPException(400, f"All {len(req.decks)} deck slots must be filled")
|
||||
raise HTTPException(400, "All 3 deck slots must be filled")
|
||||
if req.numGames < 1 or req.numGames > 10000:
|
||||
raise HTTPException(400, "numGames must be 1-10000")
|
||||
if req.threads < 1 or req.threads > 16:
|
||||
@@ -569,7 +512,7 @@ async def list_decks():
|
||||
})
|
||||
seen_names.add(db_name.lower())
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
|
||||
print(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
|
||||
|
||||
return {"decks": decks}
|
||||
|
||||
@@ -690,11 +633,7 @@ def parse_dck_file(deck_path: str) -> dict:
|
||||
qty = int(m.group(1))
|
||||
name = m.group(2).strip()
|
||||
set_code = m.group(3).strip() if m.group(3) else ""
|
||||
cards.append({
|
||||
"quantity": qty, "name": name, "set": set_code,
|
||||
"section": section,
|
||||
"is_commander": 1 if section == "Commander" else 0,
|
||||
})
|
||||
cards.append({"quantity": qty, "name": name, "set": set_code, "section": section})
|
||||
if section == "Commander":
|
||||
commander = name
|
||||
total = sum(c["quantity"] for c in cards)
|
||||
@@ -780,10 +719,10 @@ def load_precon_index():
|
||||
if idx_path.exists():
|
||||
with open(idx_path, "r", encoding="utf-8") as f:
|
||||
PRECON_INDEX = json.load(f)
|
||||
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
print(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
|
||||
else:
|
||||
PRECON_INDEX = []
|
||||
log.info(f" Precons: index not found at {idx_path}")
|
||||
print(f" Precons: index not found at {idx_path}")
|
||||
|
||||
|
||||
@app.get("/api/lab/precons")
|
||||
@@ -927,7 +866,7 @@ def download_precon_database(force: bool = False) -> dict:
|
||||
if len(existing) > 50:
|
||||
age_hours = (time.time() - idx_path.stat().st_mtime) / 3600
|
||||
if age_hours < PRECON_CACHE_HOURS:
|
||||
log.info(f" Precons: {len(existing)} decks cached "
|
||||
print(f" Precons: {len(existing)} decks cached "
|
||||
f"({age_hours:.0f}h old, refresh after {PRECON_CACHE_HOURS}h)")
|
||||
PRECON_INDEX = existing
|
||||
return {"downloaded": 0, "skipped": True, "total": len(existing),
|
||||
@@ -936,7 +875,7 @@ def download_precon_database(force: bool = False) -> dict:
|
||||
pass # Fall through to download
|
||||
|
||||
# ── Download ─────────────────────────────────────────────
|
||||
log.info(" Precons: Downloading full precon database from GitHub...")
|
||||
print(" Precons: Downloading full precon database from GitHub...")
|
||||
try:
|
||||
req = Request(GITHUB_PRECON_URL, headers={"User-Agent": "CommanderAILab/3.0"})
|
||||
with urlopen(req, timeout=120) as resp:
|
||||
@@ -944,7 +883,7 @@ def download_precon_database(force: bool = False) -> dict:
|
||||
all_decks = json.loads(raw)
|
||||
except Exception as e:
|
||||
msg = f"Failed to download precon database: {e}"
|
||||
log.error(f" Precons: ERROR — {msg}")
|
||||
print(f" Precons: ERROR — {msg}")
|
||||
# Fall back to whatever we have locally
|
||||
if idx_path.exists():
|
||||
load_precon_index()
|
||||
@@ -956,7 +895,7 @@ def download_precon_database(force: bool = False) -> dict:
|
||||
if d.get('type') == 'Commander Deck'
|
||||
and (d.get('format') or '').lower() == 'commander'
|
||||
]
|
||||
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
print(f" Precons: Found {len(commander_decks)} Commander precon decks")
|
||||
|
||||
# ── Ensure precon-decks dir exists ───────────────────────
|
||||
PRECON_DIR.mkdir(parents=True, exist_ok=True)
|
||||
@@ -1014,7 +953,7 @@ def download_precon_database(force: bool = False) -> dict:
|
||||
json.dump(index, f, indent=2, ensure_ascii=False)
|
||||
|
||||
PRECON_INDEX = index
|
||||
log.info(f" Precons: {written} .dck files written, index saved")
|
||||
print(f" Precons: {written} .dck files written, index saved")
|
||||
return {"downloaded": written, "skipped": False, "total": written, "error": None}
|
||||
|
||||
|
||||
@@ -1256,7 +1195,7 @@ def _save_profile_to_dck(profile: dict) -> Path:
|
||||
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_collect.info(f" Saved .dck: {out_path}")
|
||||
print(f" [Import] Saved .dck: {out_path}")
|
||||
return out_path
|
||||
|
||||
|
||||
@@ -1300,7 +1239,7 @@ async def run_batch_subprocess(
|
||||
ai_think_time_ms=ai_think_time_ms,
|
||||
max_queue_depth=max_queue_depth,
|
||||
)
|
||||
log_batch.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
|
||||
print(f"[Lab API] Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
|
||||
loop = asyncio.get_event_loop()
|
||||
await loop.run_in_executor(
|
||||
None,
|
||||
@@ -1309,7 +1248,7 @@ async def run_batch_subprocess(
|
||||
except Exception as e:
|
||||
state.error = str(e)
|
||||
state.running = False
|
||||
log_batch.error(f"Batch {state.batch_id} ERROR: {e}")
|
||||
print(f"[Lab API] Batch {state.batch_id} ERROR: {e}")
|
||||
|
||||
|
||||
def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
@@ -1327,9 +1266,9 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
dbg.write(f"Forge JAR exists: {os.path.exists(CFG.forge_jar)}\n")
|
||||
dbg.write(f"Forge Dir exists: {os.path.isdir(CFG.forge_dir)}\n")
|
||||
dbg.write(f"{'-'*60}\n")
|
||||
log_batch.warning(f"Debug log: {debug_log_path}")
|
||||
print(f"[Lab API] Debug log: {debug_log_path}")
|
||||
except Exception as e:
|
||||
log_batch.warning(f"Could not write debug log header: {e}")
|
||||
print(f"[Lab API] Could not write debug log header: {e}")
|
||||
|
||||
# Ensure Forge subprocesses use Java 17 (Forge crashes on Java 25+)
|
||||
env = os.environ.copy()
|
||||
@@ -1339,7 +1278,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
java17_home = os.path.dirname(java17_bin)
|
||||
env['JAVA_HOME'] = java17_home
|
||||
env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '')
|
||||
log_batch.info(f'Using Java 17: {java17}')
|
||||
print(f'[Lab API] Using Java 17: {java17}')
|
||||
|
||||
# NOTE: Do NOT set _JAVA_OPTIONS=-Djava.awt.headless=true here.
|
||||
# Forge sim mode needs AWT classes for card data initialization;
|
||||
@@ -1370,7 +1309,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
time.sleep(30)
|
||||
elapsed_since_activity = time.time() - last_activity[0]
|
||||
if elapsed_since_activity > stall_limit:
|
||||
log_batch.warning(f'WATCHDOG: No output for {int(elapsed_since_activity)}s. Killing process.')
|
||||
print(f'[Lab API] WATCHDOG: No output for {int(elapsed_since_activity)}s. Killing process.')
|
||||
state.log_lines.append(f'[WATCHDOG] Process stalled ({int(elapsed_since_activity)}s with no output). Killed.')
|
||||
proc.kill()
|
||||
return
|
||||
@@ -1406,7 +1345,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
log_batch.info(f" [{state.batch_id}] {line}")
|
||||
print(f" [{state.batch_id}] {line}")
|
||||
|
||||
proc.wait()
|
||||
|
||||
@@ -1428,7 +1367,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
dbg.write("<< NO OUTPUT >>\n")
|
||||
dbg.write(f"-- END OUTPUT --\n\n")
|
||||
except Exception as e:
|
||||
log_batch.warning(f"Could not write debug log output: {e}")
|
||||
print(f"[Lab API] Could not write debug log output: {e}")
|
||||
|
||||
if proc.returncode != 0:
|
||||
if proc.returncode == -9 or proc.returncode == 137:
|
||||
@@ -1437,7 +1376,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
state.error = f'Java process exited with code {proc.returncode}. Check forge-sim-debug.log for details.'
|
||||
else:
|
||||
state.completed_games = state.total_games
|
||||
log_batch.info(f"Batch {state.batch_id} completed in {elapsed}ms")
|
||||
print(f"[Lab API] Batch {state.batch_id} completed in {elapsed}ms")
|
||||
|
||||
# Auto-generate deck reports for the coach
|
||||
if state.result_path and os.path.exists(state.result_path):
|
||||
@@ -1447,9 +1386,9 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
|
||||
reports_dir = str(lab_root / "deck-reports")
|
||||
updated = generate_single_deck_report(state.result_path, reports_dir)
|
||||
if updated:
|
||||
log_batch.info(f"Deck reports updated: {', '.join(updated)}")
|
||||
print(f"[Lab API] Deck reports updated: {', '.join(updated)}")
|
||||
except Exception as rpt_err:
|
||||
log_batch.info(f"Deck report generation failed (non-fatal): {rpt_err}")
|
||||
print(f"[Lab API] Deck report generation failed (non-fatal): {rpt_err}")
|
||||
|
||||
except Exception as e:
|
||||
state.error = str(e)
|
||||
@@ -1512,41 +1451,32 @@ def _load_deck_cards_by_name(deck_name: str) -> list[dict]:
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
log_sim.error(f"DB lookup failed for '{deck_name}': {e}")
|
||||
print(f"[DeepSeek Batch] DB lookup failed for '{deck_name}': {e}")
|
||||
|
||||
# 2. Try .dck file
|
||||
if CFG.forge_decks_dir and os.path.isdir(CFG.forge_decks_dir):
|
||||
dck_path = Path(CFG.forge_decks_dir) / f"{deck_name}.dck"
|
||||
if dck_path.exists():
|
||||
cards = []
|
||||
current_section = None
|
||||
in_cards = False # True when inside [Main], [Deck], or [Commander]
|
||||
with open(dck_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
low = line.lower()
|
||||
if low == '[commander]':
|
||||
current_section = 'commander'
|
||||
if low in ('[main]', '[deck]', '[commander]'):
|
||||
in_cards = True
|
||||
continue
|
||||
elif low in ('[main]', '[deck]'):
|
||||
current_section = 'main'
|
||||
if line.startswith('['):
|
||||
in_cards = False
|
||||
continue
|
||||
elif line.startswith('['):
|
||||
current_section = None
|
||||
continue
|
||||
if current_section and line:
|
||||
if in_cards and line:
|
||||
parts = line.split(' ', 1)
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
qty = int(parts[0])
|
||||
name = parts[1].strip()
|
||||
is_cmdr = 1 if current_section == 'commander' else 0
|
||||
for _ in range(qty):
|
||||
cards.append({
|
||||
'name': name,
|
||||
'type_line': '',
|
||||
'cmc': 0,
|
||||
'is_commander': is_cmdr,
|
||||
})
|
||||
cards.append({'name': name, 'type_line': '', 'cmc': 0})
|
||||
except ValueError:
|
||||
pass
|
||||
if cards:
|
||||
@@ -1822,7 +1752,7 @@ def _run_deepseek_batch_thread(
|
||||
for ds in all_deck_results:
|
||||
state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)')
|
||||
|
||||
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
|
||||
print(f'[DeepSeek Batch] Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1925,11 +1855,9 @@ def build_java_command(
|
||||
java17, "-jar", CFG.lab_jar,
|
||||
"--forge-jar", CFG.forge_jar,
|
||||
"--forge-dir", CFG.forge_dir,
|
||||
]
|
||||
# Add deck args dynamically (2-4 decks)
|
||||
for i, deck in enumerate(decks):
|
||||
cmd.extend([f"--deck{i+1}", deck])
|
||||
cmd += [
|
||||
"--deck1", decks[0],
|
||||
"--deck2", decks[1],
|
||||
"--deck3", decks[2],
|
||||
"--games", str(num_games),
|
||||
"--threads", str(threads),
|
||||
"--clock", str(clock),
|
||||
@@ -2087,10 +2015,10 @@ def init_collection_db():
|
||||
for col_name, col_def in _migrate_columns:
|
||||
if col_name not in existing_cols:
|
||||
conn.execute(f"ALTER TABLE collection_entries ADD COLUMN {col_name} {col_def}")
|
||||
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
|
||||
print(f" Migration: added column '{col_name}' to collection_entries")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
|
||||
print(f" Collection DB: {COLLECTION_DB_PATH}")
|
||||
|
||||
|
||||
# Fields stored as JSON strings in SQLite that should be returned as parsed arrays
|
||||
@@ -2560,147 +2488,6 @@ async def update_collection_card(cardId: int, body: dict):
|
||||
# Collection Import Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── Scryfall Response Cache ─────────────────────────────────
|
||||
# SQLite-backed cache for Scryfall API responses.
|
||||
# Keyed by (name, set_code, collector_number). Default TTL: 7 days.
|
||||
# Scryfall data changes infrequently (set releases ~every 3 months).
|
||||
# Eliminates redundant HTTP calls across imports and re-enrichments.
|
||||
|
||||
SCRYFALL_CACHE_DB_PATH = Path(__file__).parent / "scryfall_cache.db"
|
||||
SCRYFALL_CACHE_TTL_SECONDS = int(os.environ.get("SCRYFALL_CACHE_TTL", 7 * 24 * 3600)) # 7 days
|
||||
|
||||
|
||||
class _ScryfallCache:
|
||||
"""
|
||||
Thread-safe SQLite cache for raw Scryfall JSON responses.
|
||||
|
||||
Schema:
|
||||
cache_key TEXT PRIMARY KEY — "name|set|cn" normalised lowercase
|
||||
json_blob TEXT — raw Scryfall API JSON response
|
||||
fetched_at TEXT — ISO-8601 UTC timestamp
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path = None, ttl_seconds: int = None):
|
||||
self._db_path = db_path or SCRYFALL_CACHE_DB_PATH
|
||||
self._ttl = ttl_seconds or SCRYFALL_CACHE_TTL_SECONDS
|
||||
self._lock = threading.Lock()
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
self._init_db()
|
||||
|
||||
# ── internal ────────────────────────────────────────────
|
||||
|
||||
def _init_db(self):
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS scryfall_cache (
|
||||
cache_key TEXT PRIMARY KEY,
|
||||
json_blob TEXT NOT NULL,
|
||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
@staticmethod
|
||||
def _make_key(name: str, set_code: str, collector_number: str) -> str:
|
||||
return f"{name.strip().lower()}|{(set_code or '').strip().lower()}|{(collector_number or '').strip()}"
|
||||
|
||||
# ── public API ──────────────────────────────────────────
|
||||
|
||||
def get(self, name: str, set_code: str = "", collector_number: str = "") -> Optional[dict]:
|
||||
"""Return cached Scryfall JSON dict, or None if missing / expired."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
with self._lock:
|
||||
row = self._conn.execute(
|
||||
"SELECT json_blob, fetched_at FROM scryfall_cache WHERE cache_key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
self._misses += 1
|
||||
return None
|
||||
# TTL check
|
||||
try:
|
||||
fetched = datetime.fromisoformat(row[1])
|
||||
age = (datetime.utcnow() - fetched).total_seconds()
|
||||
if age > self._ttl:
|
||||
self._misses += 1
|
||||
return None
|
||||
except (ValueError, TypeError):
|
||||
self._misses += 1
|
||||
return None
|
||||
self._hits += 1
|
||||
try:
|
||||
return json.loads(row[0])
|
||||
except json.JSONDecodeError:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
def put(self, name: str, set_code: str, collector_number: str, card_data: dict):
|
||||
"""Store a Scryfall response in the cache."""
|
||||
key = self._make_key(name, set_code, collector_number)
|
||||
blob = json.dumps(card_data, separators=(",", ":"))
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"INSERT OR REPLACE INTO scryfall_cache (cache_key, json_blob, fetched_at) "
|
||||
"VALUES (?, ?, datetime('now'))",
|
||||
(key, blob),
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def stats(self) -> dict:
|
||||
"""Return cache statistics."""
|
||||
with self._lock:
|
||||
total = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
oldest_row = self._conn.execute(
|
||||
"SELECT MIN(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
newest_row = self._conn.execute(
|
||||
"SELECT MAX(fetched_at) FROM scryfall_cache"
|
||||
).fetchone()
|
||||
db_size_bytes = os.path.getsize(str(self._db_path)) if self._db_path.exists() else 0
|
||||
return {
|
||||
"total_entries": total,
|
||||
"hits": self._hits,
|
||||
"misses": self._misses,
|
||||
"hit_rate": round(self._hits / max(self._hits + self._misses, 1) * 100, 1),
|
||||
"ttl_seconds": self._ttl,
|
||||
"ttl_days": round(self._ttl / 86400, 1),
|
||||
"oldest_entry": oldest_row[0] if oldest_row else None,
|
||||
"newest_entry": newest_row[0] if newest_row else None,
|
||||
"db_size_kb": round(db_size_bytes / 1024, 1),
|
||||
"db_path": str(self._db_path),
|
||||
}
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Delete all cached entries. Returns count of deleted rows."""
|
||||
with self._lock:
|
||||
count = self._conn.execute("SELECT COUNT(*) FROM scryfall_cache").fetchone()[0]
|
||||
self._conn.execute("DELETE FROM scryfall_cache")
|
||||
self._conn.commit()
|
||||
self._conn.execute("VACUUM") # must run outside transaction
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
return count
|
||||
|
||||
def evict_expired(self) -> int:
|
||||
"""Remove entries older than TTL. Returns count of evicted rows."""
|
||||
cutoff = datetime.utcnow().isoformat()
|
||||
with self._lock:
|
||||
self._conn.execute(
|
||||
"DELETE FROM scryfall_cache WHERE "
|
||||
"(julianday(?) - julianday(fetched_at)) * 86400 > ?",
|
||||
(cutoff, self._ttl),
|
||||
)
|
||||
deleted = self._conn.total_changes
|
||||
self._conn.commit()
|
||||
return deleted
|
||||
|
||||
|
||||
_scryfall_cache = _ScryfallCache()
|
||||
|
||||
|
||||
# Scryfall rate-limit semaphore (max 10 req/sec)
|
||||
_scryfall_lock = threading.Lock()
|
||||
_scryfall_last_call = 0.0
|
||||
@@ -2884,26 +2671,20 @@ def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> li
|
||||
return rows
|
||||
|
||||
|
||||
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch raw card data from Scryfall API (with caching).
|
||||
Returns the raw Scryfall JSON dict, or a dict with '_error' on failure.
|
||||
Cache is checked first; live HTTP call only on miss.
|
||||
Fetch card data from Scryfall and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
_scryfall_rate_limit()
|
||||
|
||||
# ── Cache check ──────────────────────────────────────
|
||||
cached = _scryfall_cache.get(name, set_code, collector_number)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# ── Live fetch ───────────────────────────────────────
|
||||
card_data = None
|
||||
last_error = ""
|
||||
|
||||
# Try set + collector number first
|
||||
if set_code and collector_number:
|
||||
_scryfall_rate_limit()
|
||||
try:
|
||||
url = f"https://api.scryfall.com/cards/{set_code.lower()}/{collector_number}"
|
||||
req = Request(url, headers=_API_HEADERS)
|
||||
@@ -2930,27 +2711,6 @@ def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "
|
||||
err_detail = card_data.get("details", "unknown error") if card_data else last_error
|
||||
return {"_error": f"Scryfall returned error for '{name}': {err_detail}"}
|
||||
|
||||
# ── Cache the successful response ────────────────────
|
||||
_scryfall_cache.put(name, set_code, collector_number, card_data)
|
||||
# Also cache under the Scryfall-resolved name (handles spelling normalisation)
|
||||
resolved_name = card_data.get("name", "")
|
||||
if resolved_name and resolved_name.lower() != name.strip().lower():
|
||||
_scryfall_cache.put(resolved_name, set_code, collector_number, card_data)
|
||||
|
||||
return card_data
|
||||
|
||||
|
||||
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
|
||||
"""
|
||||
Fetch card data from Scryfall (cached) and return enriched fields dict.
|
||||
Tries set+collectorNumber first, then exact name lookup.
|
||||
Returns empty dict on failure.
|
||||
"""
|
||||
card_data = _fetch_scryfall_api(name, set_code, collector_number)
|
||||
|
||||
if not card_data or "_error" in card_data:
|
||||
return card_data or {}
|
||||
|
||||
# Extract fields
|
||||
type_line = card_data.get("type_line", "")
|
||||
color_identity = card_data.get("color_identity", [])
|
||||
@@ -3167,31 +2927,6 @@ async def import_collection(body: dict):
|
||||
}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Scryfall Cache Management Endpoints
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
@app.get("/api/cache/scryfall")
|
||||
async def scryfall_cache_stats():
|
||||
"""Return Scryfall response cache statistics."""
|
||||
return _scryfall_cache.stats()
|
||||
|
||||
|
||||
@app.delete("/api/cache/scryfall")
|
||||
async def scryfall_cache_clear():
|
||||
"""Clear all cached Scryfall responses."""
|
||||
deleted = _scryfall_cache.clear()
|
||||
return {"cleared": deleted, "message": f"Deleted {deleted} cached entries"}
|
||||
|
||||
|
||||
@app.post("/api/cache/scryfall/evict")
|
||||
async def scryfall_cache_evict_expired():
|
||||
"""Remove only expired entries (older than TTL) from the cache."""
|
||||
evicted = _scryfall_cache.evict_expired()
|
||||
return {"evicted": evicted, "message": f"Evicted {evicted} expired entries"}
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Deck Builder Helpers
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
@@ -4253,7 +3988,7 @@ async def export_deck_to_sim(deck_id: int):
|
||||
|
||||
out_path = Path(save_dir) / f"{safe_name}.dck"
|
||||
out_path.write_text(content, encoding="utf-8")
|
||||
log_deckgen.info(f" Exported deck {deck_id} to {out_path}")
|
||||
print(f" [DeckBuilder] Exported deck {deck_id} to {out_path}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -4579,7 +4314,7 @@ async def scan_card_image(request: FastAPIRequest):
|
||||
if len(image_bytes) > 20 * 1024 * 1024:
|
||||
raise HTTPException(400, "Image file too large (max 20 MB)")
|
||||
|
||||
log_scan.info(f" Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
|
||||
print(f" [SCAN] Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
|
||||
|
||||
if mode == "multi":
|
||||
results = scan_multi(image_bytes, _scryfall_fuzzy_lookup, ximilar_api_key=api_key)
|
||||
@@ -4990,7 +4725,7 @@ async def get_card_edhrec(cardId: int):
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Error fetching data for '{name}': {e}")
|
||||
print(f" [EDHREC] Error fetching data for '{name}': {e}")
|
||||
# Graceful degradation — return empty results
|
||||
|
||||
result = {
|
||||
@@ -5417,7 +5152,7 @@ def _get_deepseek_brain():
|
||||
cfg.log_dir = _os2.path.join(_os2.path.dirname(_os2.path.abspath(__file__)), 'logs', 'decisions')
|
||||
_deepseek_brain = DeepSeekBrain(cfg)
|
||||
except Exception as e:
|
||||
log_sim.error(f'Failed to initialize brain: {e}')
|
||||
print(f'[DeepSeek] Failed to initialize brain: {e}')
|
||||
return None
|
||||
return _deepseek_brain
|
||||
|
||||
@@ -5916,11 +5651,11 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
return {"error": "Commander not found. Please check the name or Scryfall ID."}
|
||||
|
||||
color_identity = req.color_identity or commander.get("color_identity", [])
|
||||
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
|
||||
print(f" [DECKGEN] Commander: {commander['name']}, CI: {color_identity}")
|
||||
|
||||
# 2. Load collection
|
||||
collection = _get_collection_for_colors(color_identity)
|
||||
log_deckgen.info(f" {len(collection)} cards in collection match color identity")
|
||||
print(f" [DECKGEN] {len(collection)} cards in collection match color identity")
|
||||
|
||||
# 3. Call source adapters (currently stubbed)
|
||||
template_cards = [] # list of (name, weight, source_name)
|
||||
@@ -5933,7 +5668,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
for tc in td.cards:
|
||||
template_cards.append((tc.name, tc.quantity, "archidekt"))
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Archidekt adapter error: {e}")
|
||||
print(f" [DECKGEN] Archidekt adapter error: {e}")
|
||||
|
||||
try:
|
||||
if sources.use_edhrec:
|
||||
@@ -5942,7 +5677,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
for tc in td.cards:
|
||||
template_cards.append((tc.name, tc.quantity, "edhrec"))
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" EDHREC adapter error: {e}")
|
||||
print(f" [DECKGEN] EDHREC adapter error: {e}")
|
||||
|
||||
try:
|
||||
if sources.use_moxfield:
|
||||
@@ -5951,7 +5686,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
for tc in td.cards:
|
||||
template_cards.append((tc.name, tc.quantity, "moxfield"))
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Moxfield adapter error: {e}")
|
||||
print(f" [DECKGEN] Moxfield adapter error: {e}")
|
||||
|
||||
try:
|
||||
if sources.use_mtggoldfish:
|
||||
@@ -5960,7 +5695,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
for tc in td.cards:
|
||||
template_cards.append((tc.name, tc.quantity, "mtggoldfish"))
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" MTGGoldfish adapter error: {e}")
|
||||
print(f" [DECKGEN] MTGGoldfish adapter error: {e}")
|
||||
|
||||
# 4. Build candidate pool from collection, scored by type need
|
||||
# Build a map: name_lower -> card dict (deduped)
|
||||
@@ -6148,8 +5883,8 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
|
||||
"is_proxy": card.get("is_proxy", False),
|
||||
})
|
||||
|
||||
log_deckgen.info(f" Generated deck: {len(clean_cards)} cards + commander")
|
||||
log_deckgen.info(f" Stats: {stats}")
|
||||
print(f" [DECKGEN] Generated deck: {len(clean_cards)} cards + commander")
|
||||
print(f" [DECKGEN] Stats: {stats}")
|
||||
|
||||
return {
|
||||
"commander": commander,
|
||||
@@ -6280,11 +6015,11 @@ async def deck_generator_commit(req: DeckGenerationRequest):
|
||||
dck_lines.append(f"{card.get('quantity', 1)} {card_name}")
|
||||
with open(dck_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(dck_lines))
|
||||
log_deckgen.info(f" Exported .dck file: {dck_path}")
|
||||
print(f" [DECKGEN] Exported .dck file: {dck_path}")
|
||||
except Exception as e:
|
||||
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
|
||||
print(f" [DECKGEN] Warning: Failed to export .dck file: {e}")
|
||||
|
||||
log_deckgen.info(f" Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander")
|
||||
print(f" [DECKGEN] Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander")
|
||||
|
||||
result["deck_id"] = deck_id
|
||||
result["deck_name"] = deck_name
|
||||
@@ -6363,7 +6098,7 @@ async def deck_generator_commander_search(q: str = ""):
|
||||
"image_url": image_uris.get("normal", image_uris.get("small", "")),
|
||||
})
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" Scryfall search error: {e}")
|
||||
print(f" [DECKGEN] Scryfall search error: {e}")
|
||||
|
||||
return {"results": results[:20]}
|
||||
|
||||
@@ -6533,7 +6268,7 @@ def _call_pplx_api(messages: list[dict], max_tokens: int = 4096, temperature: fl
|
||||
raise ValueError('Empty response from Perplexity API')
|
||||
content = choices[0].get('message', {}).get('content', '')
|
||||
usage = data.get('usage', {})
|
||||
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
|
||||
print(f'[pplx-api] tokens: prompt={usage.get("prompt_tokens","?")}, '
|
||||
f'completion={usage.get("completion_tokens","?")}, '
|
||||
f'model={data.get("model","?")}')
|
||||
return content
|
||||
@@ -6848,7 +6583,7 @@ async def deck_generate_ai(req: DeckGenerateAIRequest):
|
||||
commander = scry_data.get('name', commander) # Use canonical name
|
||||
commander_type = scry_data.get('type_line', '')
|
||||
except Exception as e:
|
||||
log_pplx.error(f'Scryfall lookup failed for "{commander}": {e}')
|
||||
print(f'[pplx-gen] Scryfall lookup failed for "{commander}": {e}')
|
||||
|
||||
# Build collection summary
|
||||
collection_block = ''
|
||||
@@ -7023,7 +6758,7 @@ async def deck_gen_v3_generate(req: DeckGenV3Request):
|
||||
except ValueError as e:
|
||||
return JSONResponse({'error': str(e)}, status_code=422)
|
||||
except Exception as e:
|
||||
log_deckgen.error(f'Error: {e}')
|
||||
print(f'[deck-gen-v3] Error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(500, f'Deck generation failed: {str(e)}')
|
||||
@@ -7130,19 +6865,19 @@ async def deck_gen_v3_commit(req: DeckGenV3Request):
|
||||
dck_lines.append(f"{card.get('count', 1)} {cname}")
|
||||
with open(dck_path, 'w', encoding='utf-8') as f:
|
||||
f.write('\n'.join(dck_lines))
|
||||
log_deckgen.info(f" Exported .dck: {dck_path}")
|
||||
print(f" [DeckGenV3] Exported .dck: {dck_path}")
|
||||
except Exception as e:
|
||||
log_deckgen.error(f" .dck export failed: {e}")
|
||||
print(f" [DeckGenV3] .dck export failed: {e}")
|
||||
|
||||
result['deck_id'] = deck_id
|
||||
result['deck_name'] = deck_name
|
||||
log_deckgen.info(f" Committed deck '{deck_name}' (ID: {deck_id})")
|
||||
print(f" [DeckGenV3] Committed deck '{deck_name}' (ID: {deck_id})")
|
||||
return result
|
||||
|
||||
except ValueError as e:
|
||||
return JSONResponse({'error': str(e)}, status_code=422)
|
||||
except Exception as e:
|
||||
log_deckgen.error(f'Commit error: {e}')
|
||||
print(f'[deck-gen-v3] Commit error: {e}')
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(500, f'Deck generation/commit failed: {str(e)}')
|
||||
@@ -7348,22 +7083,22 @@ def init_coach_service():
|
||||
from coach.config import EMBEDDINGS_NPZ
|
||||
if EMBEDDINGS_NPZ.exists():
|
||||
_coach_embeddings.load()
|
||||
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
print(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
|
||||
else:
|
||||
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
|
||||
print(" Coach: Embeddings not yet downloaded (will download on first use)")
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings load failed: {e}")
|
||||
print(f" Coach: Embeddings load failed: {e}")
|
||||
|
||||
_coach_service = CoachService(_coach_llm, _coach_embeddings)
|
||||
|
||||
# Check LLM connection
|
||||
llm_status = _coach_llm.check_connection()
|
||||
if llm_status.get("connected"):
|
||||
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
print(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
|
||||
else:
|
||||
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
|
||||
print(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
|
||||
|
||||
log_coach.info(" Coach: Service initialized")
|
||||
print(" Coach: Service initialized")
|
||||
|
||||
# Initialize V3 Deck Generator (Perplexity)
|
||||
_deck_gen_v3_error = None
|
||||
@@ -7382,21 +7117,21 @@ def init_coach_service():
|
||||
db_conn_factory=_get_db_conn,
|
||||
embedding_index=_coach_embeddings,
|
||||
)
|
||||
log_deckgen.info(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
print(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
|
||||
except ImportError as e:
|
||||
_deck_gen_v3_error = f"Missing dependency: {e}. Run: pip install openai"
|
||||
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
print(f" Deck Gen V3: {_deck_gen_v3_error}")
|
||||
_deck_gen_v3 = None
|
||||
except Exception as e:
|
||||
_deck_gen_v3_error = str(e)
|
||||
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
print(f" Deck Gen V3: Failed to initialize: {e}")
|
||||
_deck_gen_v3 = None
|
||||
else:
|
||||
_deck_gen_v3_error = "PPLX_API_KEY not set"
|
||||
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
print(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
|
||||
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Failed to initialize: {e}")
|
||||
print(f" Coach: Failed to initialize: {e}")
|
||||
_coach_service = None
|
||||
|
||||
|
||||
@@ -7499,7 +7234,7 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
try:
|
||||
_coach_embeddings.load(force_download=True)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Embeddings download failed: {e}")
|
||||
print(f" Coach: Embeddings download failed: {e}")
|
||||
|
||||
goals = None
|
||||
if body and body.goals:
|
||||
@@ -7514,7 +7249,7 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
|
||||
try:
|
||||
fallback_report = _build_deck_report_from_db(deck_id)
|
||||
except Exception as e:
|
||||
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
print(f" Coach: Fallback report build failed for '{deck_id}': {e}")
|
||||
|
||||
try:
|
||||
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
|
||||
@@ -8070,11 +7805,11 @@ def _get_policy_service():
|
||||
from ml.serving.policy_server import PolicyInferenceService
|
||||
_policy_service = PolicyInferenceService()
|
||||
if _policy_service.load():
|
||||
log_ml.info(f"Policy model loaded on {_policy_service.device}")
|
||||
print(f"[ML] Policy model loaded on {_policy_service.device}")
|
||||
else:
|
||||
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
|
||||
print(f"[ML] Policy model not available: {_policy_service._load_error}")
|
||||
except Exception as e:
|
||||
log_ml.error(f"Policy service init failed: {e}")
|
||||
print(f"[ML] Policy service init failed: {e}")
|
||||
return _policy_service
|
||||
|
||||
|
||||
@@ -8234,7 +7969,7 @@ def _run_training_pipeline(
|
||||
if rebuild_dataset or not os.path.exists(train_path):
|
||||
_training_state["phase"] = "building"
|
||||
_training_state["message"] = "Loading card embeddings & building dataset..."
|
||||
log_ml.info("Building dataset (loading embeddings, may auto-download)...")
|
||||
print("[ML Train] Building dataset (loading embeddings, may auto-download)...")
|
||||
|
||||
from ml.data.dataset_builder import build_dataset, split_dataset, save_dataset
|
||||
dataset = build_dataset(results_dir=results_dir)
|
||||
@@ -8246,14 +7981,14 @@ def _run_training_pipeline(
|
||||
save_dataset(test_ds, os.path.join(data_dir, "test.npz"))
|
||||
total_samples = len(dataset["states"])
|
||||
_training_state["message"] = f"Dataset built: {total_samples} samples"
|
||||
log_ml.info(f"Dataset built: {total_samples} samples")
|
||||
print(f"[ML Train] Dataset built: {total_samples} samples")
|
||||
|
||||
# --- Phase 2: Train ---
|
||||
_training_state["phase"] = "training"
|
||||
_training_state["total_epochs"] = epochs
|
||||
_training_state["current_epoch"] = 0
|
||||
_training_state["message"] = f"Training policy network ({epochs} epochs)..."
|
||||
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
print(f"[ML Train] Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -8325,13 +8060,13 @@ def _run_training_pipeline(
|
||||
"device": device,
|
||||
}
|
||||
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
|
||||
log_ml.info(f"Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
print(f"[ML Train] Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
|
||||
|
||||
# Auto-reload policy server with new checkpoint
|
||||
global _policy_service, _policy_service_init_attempted
|
||||
if _policy_service and _policy_service._loaded:
|
||||
_policy_service.reload(summary.get("checkpoint_path"))
|
||||
log_ml.info("Policy server reloaded with new checkpoint")
|
||||
print("[ML Train] Policy server reloaded with new checkpoint")
|
||||
elif not _policy_service_init_attempted:
|
||||
_policy_service_init_attempted = False # Allow re-init with new model
|
||||
|
||||
@@ -8341,7 +8076,7 @@ def _run_training_pipeline(
|
||||
_training_state["running"] = False
|
||||
_training_state["error"] = str(e)
|
||||
_training_state["message"] = f"Training failed: {e}"
|
||||
log_ml.error(f"ERROR: {e}")
|
||||
print(f"[ML Train] ERROR: {e}")
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
@@ -8750,8 +8485,6 @@ def parse_args():
|
||||
help="Ximilar API key for card scanner (visual AI recognition)")
|
||||
parser.add_argument("--pplx-key", default=os.environ.get("PPLX_API_KEY", "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"),
|
||||
help="Perplexity API key for AI deck research/generation (env: PPLX_API_KEY)")
|
||||
parser.add_argument("--verbose", "-v", action="store_true",
|
||||
help="Enable DEBUG-level logging (default: INFO)")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -8795,18 +8528,17 @@ def load_commander_meta():
|
||||
try:
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
COMMANDER_META = json.load(f)
|
||||
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
print(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
|
||||
return
|
||||
except Exception as e:
|
||||
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
print(f" WARNING: Failed to load commander-meta.json: {e}")
|
||||
|
||||
COMMANDER_META = BUILTIN_COMMANDERS
|
||||
log.info(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
print(f" Meta: {len(COMMANDER_META)} built-in commanders")
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
setup_logging(logging.DEBUG if getattr(args, 'verbose', False) else logging.INFO)
|
||||
|
||||
CFG.forge_jar = args.forge_jar
|
||||
CFG.forge_dir = args.forge_dir
|
||||
@@ -8816,21 +8548,21 @@ def main():
|
||||
CFG.ximilar_api_key = args.ximilar_key
|
||||
CFG.pplx_api_key = args.pplx_key
|
||||
|
||||
log.info("╔══════════════════════════════════════════════════╗")
|
||||
log.info("║ Commander AI Lab — API Server v3.0.0 ║")
|
||||
log.info("╚══════════════════════════════════════════════════╝")
|
||||
log.info("")
|
||||
log.info(f" Forge JAR: {CFG.forge_jar}")
|
||||
log.info(f" Forge Dir: {CFG.forge_dir}")
|
||||
log.info(f" Decks Dir: {CFG.forge_decks_dir}")
|
||||
log.info(f" Lab JAR: {CFG.lab_jar}")
|
||||
log.info(f" Results Dir: {CFG.results_dir}")
|
||||
log.info(f" Port: {CFG.port}")
|
||||
log.info(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET (scanner will fail)'}")
|
||||
log.info(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET (AI research/gen disabled)'}")
|
||||
print("╔══════════════════════════════════════════════════╗")
|
||||
print("║ Commander AI Lab — API Server v3.0.0 ║")
|
||||
print("╚══════════════════════════════════════════════════╝")
|
||||
print()
|
||||
print(f" Forge JAR: {CFG.forge_jar}")
|
||||
print(f" Forge Dir: {CFG.forge_dir}")
|
||||
print(f" Decks Dir: {CFG.forge_decks_dir}")
|
||||
print(f" Lab JAR: {CFG.lab_jar}")
|
||||
print(f" Results Dir: {CFG.results_dir}")
|
||||
print(f" Port: {CFG.port}")
|
||||
print(f" Ximilar: {'configured' if CFG.ximilar_api_key else 'NOT SET (scanner will fail)'}")
|
||||
print(f" Perplexity: {'configured' if CFG.pplx_api_key else 'NOT SET (AI research/gen disabled)'}")
|
||||
j17 = get_java17()
|
||||
log.info(f" Java 17: {j17 if j17 != 'java' else 'NOT FOUND (batch sim may fail on Java 25+)'}")
|
||||
log.info(f" LM Studio: http://192.168.0.122:1234")
|
||||
print(f" Java 17: {j17 if j17 != 'java' else 'NOT FOUND (batch sim may fail on Java 25+)'}")
|
||||
print(f" LM Studio: http://192.168.0.122:1234")
|
||||
|
||||
load_commander_meta()
|
||||
download_precon_database() # Auto-downloads all 163+ Commander precons on first run
|
||||
@@ -8839,27 +8571,27 @@ def main():
|
||||
|
||||
# Ximilar API key check
|
||||
if not CFG.ximilar_api_key:
|
||||
log_scan.warning(" WARNING: --ximilar-key not set. Card scanner will not work.")
|
||||
log.info(" Set via CLI: --ximilar-key YOUR_KEY")
|
||||
log.info(" Or env var: XIMILAR_API_KEY=YOUR_KEY")
|
||||
print(" WARNING: --ximilar-key not set. Card scanner will not work.")
|
||||
print(" Set via CLI: --ximilar-key YOUR_KEY")
|
||||
print(" Or env var: XIMILAR_API_KEY=YOUR_KEY")
|
||||
|
||||
if not CFG.forge_jar:
|
||||
log.warning("WARNING: --forge-jar not set. /api/lab/start will fail.")
|
||||
print("WARNING: --forge-jar not set. /api/lab/start will fail.")
|
||||
if not CFG.lab_jar:
|
||||
log.warning("WARNING: Lab JAR not found. Build with: mvn package -DskipTests")
|
||||
print("WARNING: Lab JAR not found. Build with: mvn package -DskipTests")
|
||||
if not CFG.forge_decks_dir:
|
||||
log.warning("WARNING: Commander decks dir not found. /api/lab/decks will return empty.")
|
||||
print("WARNING: Commander decks dir not found. /api/lab/decks will return empty.")
|
||||
|
||||
log.info("")
|
||||
log.info(f" Starting server on http://localhost:{CFG.port}")
|
||||
print()
|
||||
print(f" Starting server on http://localhost:{CFG.port}")
|
||||
if _spa_dir.exists():
|
||||
log.info(f" Web UI: http://localhost:{CFG.port}/ (React SPA)")
|
||||
log.info(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training")
|
||||
print(f" Web UI: http://localhost:{CFG.port}/ (React SPA)")
|
||||
print(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training")
|
||||
else:
|
||||
log.info(f" Web UI: http://localhost:{CFG.port}/index.html (legacy HTML)")
|
||||
log.info(f" NOTE: React SPA not built. Run: cd frontend/commander-ai-lab-ui && npm install && npm run build")
|
||||
log.info(f" API docs: http://localhost:{CFG.port}/docs")
|
||||
log.info("")
|
||||
print(f" Web UI: http://localhost:{CFG.port}/index.html (legacy HTML)")
|
||||
print(f" NOTE: React SPA not built. Run: cd frontend/commander-ai-lab-ui && npm install && npm run build")
|
||||
print(f" API docs: http://localhost:{CFG.port}/docs")
|
||||
print()
|
||||
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
|
||||
|
||||
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
|
||||
dist[action.value] = count
|
||||
|
||||
total = len(labels)
|
||||
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
log.info("─" * 43)
|
||||
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
|
||||
print("─" * 43)
|
||||
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
|
||||
pct = (count / total * 100) if total > 0 else 0
|
||||
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
log.info(f"{'─' * 43}")
|
||||
log.info(f" {'TOTAL':<23} {total:>8}")
|
||||
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
|
||||
print(f"{'─' * 43}")
|
||||
print(f" {'TOTAL':<23} {total:>8}")
|
||||
|
||||
return dist
|
||||
@@ -154,17 +154,17 @@ def build_dataset(
|
||||
|
||||
# Print game outcome distribution
|
||||
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
|
||||
log.info(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
print(f"\n{'Outcome':<25} {'Count':>8}")
|
||||
print("─" * 35)
|
||||
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(o):<23} {c:>8}")
|
||||
print(f" {str(o):<23} {c:>8}")
|
||||
|
||||
# Print playstyle distribution
|
||||
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
|
||||
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
log.info("─" * 35)
|
||||
print(f"\n{'Playstyle':<25} {'Count':>8}")
|
||||
print("─" * 35)
|
||||
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
|
||||
log.info(f" {str(s):<23} {c:>8}")
|
||||
print(f" {str(s):<23} {c:>8}")
|
||||
|
||||
return {
|
||||
"states": states,
|
||||
@@ -288,7 +288,7 @@ def main():
|
||||
max_samples=args.max_samples,
|
||||
)
|
||||
if not dataset:
|
||||
log.info("\nNo data to save. Run simulations with --ml-log first.")
|
||||
print("\nNo data to save. Run simulations with --ml-log first.")
|
||||
sys.exit(1)
|
||||
|
||||
if args.no_split:
|
||||
@@ -300,7 +300,7 @@ def main():
|
||||
save_dataset(val, f"{base}-val.npz")
|
||||
save_dataset(test, f"{base}-test.npz")
|
||||
|
||||
log.info("\nDataset build complete.")
|
||||
print("\nDataset build complete.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -16,10 +16,6 @@ The network is designed for:
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.ml.training")
|
||||
|
||||
|
||||
# Conditional torch import — graceful failure if not installed
|
||||
try:
|
||||
@@ -29,7 +25,7 @@ try:
|
||||
TORCH_AVAILABLE = True
|
||||
except ImportError:
|
||||
TORCH_AVAILABLE = False
|
||||
log.warning("PyTorch not installed. Install with: pip install torch")
|
||||
print("[ML] PyTorch not installed. Install with: pip install torch")
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -208,7 +204,7 @@ def save_checkpoint(
|
||||
"dropout": TRAINING_CONFIG.dropout,
|
||||
},
|
||||
}, path)
|
||||
log.info(f"Checkpoint saved: {path}")
|
||||
print(f"[ML] Checkpoint saved: {path}")
|
||||
|
||||
|
||||
def load_checkpoint(path: str, device: str = "cpu"):
|
||||
|
||||
+27
-26
@@ -49,7 +49,8 @@ from ml.training.policy_network import (
|
||||
PolicyNetwork, save_checkpoint, load_checkpoint,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.ml.training")
|
||||
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
|
||||
logger = logging.getLogger("ml.trainer")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -501,40 +502,40 @@ def evaluate_model(
|
||||
|
||||
def print_evaluation(results: Dict):
|
||||
"""Pretty-print evaluation results."""
|
||||
logger.info("")
|
||||
logger.info("=" * 65)
|
||||
logger.info(" Test Set Evaluation")
|
||||
logger.info("=" * 65)
|
||||
logger.info(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
|
||||
logger.info(f" Total Samples: {results['total_samples']:,}")
|
||||
logger.info("")
|
||||
logger.info(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
logger.info(" " + "-" * 55)
|
||||
print()
|
||||
print("=" * 65)
|
||||
print(" Test Set Evaluation")
|
||||
print("=" * 65)
|
||||
print(f" Overall Accuracy: {results['overall_accuracy']:.1%}")
|
||||
print(f" Total Samples: {results['total_samples']:,}")
|
||||
print()
|
||||
print(f" {'Action':<20} {'Support':>8} {'Prec':>7} {'Recall':>7} {'F1':>7}")
|
||||
print(" " + "-" * 55)
|
||||
|
||||
for action_name, m in results["per_class"].items():
|
||||
logger.info(
|
||||
print(
|
||||
f" {action_name:<20} {m['support']:>8} "
|
||||
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
|
||||
)
|
||||
|
||||
logger.info(" " + "-" * 55)
|
||||
print(" " + "-" * 55)
|
||||
|
||||
# Confusion matrix
|
||||
logger.info("")
|
||||
logger.info(" Confusion Matrix (rows=true, cols=predicted):")
|
||||
print()
|
||||
print(" Confusion Matrix (rows=true, cols=predicted):")
|
||||
header = " " + " " * 18
|
||||
for c in range(NUM_ACTIONS):
|
||||
short_name = IDX_TO_ACTION[c].value[:6]
|
||||
header += f" {short_name:>6}"
|
||||
logger.info(header)
|
||||
print(header)
|
||||
|
||||
for r in range(NUM_ACTIONS):
|
||||
row_name = IDX_TO_ACTION[r].value[:16]
|
||||
row_str = f" {row_name:<18}"
|
||||
for c in range(NUM_ACTIONS):
|
||||
row_str += f" {results['confusion_matrix'][r][c]:>6}"
|
||||
logger.info(row_str)
|
||||
logger.info("")
|
||||
print(row_str)
|
||||
print()
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════
|
||||
@@ -604,8 +605,8 @@ def main():
|
||||
args = parser.parse_args()
|
||||
|
||||
if not TORCH_AVAILABLE:
|
||||
logger.info("PyTorch not installed. Install with:")
|
||||
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
|
||||
print("PyTorch not installed. Install with:")
|
||||
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
|
||||
sys.exit(1)
|
||||
|
||||
device = args.device or get_best_device()
|
||||
@@ -620,13 +621,13 @@ def main():
|
||||
if args.eval_only:
|
||||
# Evaluation only
|
||||
if not os.path.exists(test_path):
|
||||
logger.info(f"Test data not found: {test_path}")
|
||||
logger.info("Run: python -m ml.scripts.ml_cli build")
|
||||
print(f"Test data not found: {test_path}")
|
||||
print("Run: python -m ml.scripts.ml_cli build")
|
||||
sys.exit(1)
|
||||
|
||||
ckpt_path = args.checkpoint or os.path.join(ckpt_dir, "best_policy.pt")
|
||||
if not os.path.exists(ckpt_path):
|
||||
logger.info(f"Checkpoint not found: {ckpt_path}")
|
||||
print(f"Checkpoint not found: {ckpt_path}")
|
||||
sys.exit(1)
|
||||
|
||||
test_states, test_labels = load_npz_split(test_path)
|
||||
@@ -640,15 +641,15 @@ def main():
|
||||
eval_path = os.path.join(ckpt_dir, "eval_results.json")
|
||||
with open(eval_path, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
logger.info(f" Results saved: {eval_path}")
|
||||
print(f" Results saved: {eval_path}")
|
||||
return
|
||||
|
||||
# Training mode
|
||||
for path, label in [(train_path, "train"), (val_path, "val")]:
|
||||
if not os.path.exists(path):
|
||||
logger.info(f"{label} data not found: {path}")
|
||||
logger.info("Build the dataset first:")
|
||||
logger.info(" python -m ml.scripts.ml_cli build --results-dir results/")
|
||||
print(f"{label} data not found: {path}")
|
||||
print("Build the dataset first:")
|
||||
print(" python -m ml.scripts.ml_cli build --results-dir results/")
|
||||
sys.exit(1)
|
||||
|
||||
train_states, train_labels = load_npz_split(train_path)
|
||||
|
||||
+79
-121
@@ -22,15 +22,11 @@ Configuration:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import logging
|
||||
import logging.handlers
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── Default Config ──────────────────────────────────────────────
|
||||
@@ -55,48 +51,10 @@ DEFAULT_CONFIG = {
|
||||
}
|
||||
|
||||
|
||||
# ── Logging ──────────────────────────────────────────────────
|
||||
_LOG_DIR = Path(os.environ.get("CAL_LOG_DIR", "logs"))
|
||||
|
||||
|
||||
def setup_overnight_logging() -> logging.Logger:
|
||||
"""Configure logging for the overnight runner.
|
||||
|
||||
Outputs to both console and a rotating log file so that
|
||||
unattended runs always have a persistent record.
|
||||
"""
|
||||
_LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
logger = logging.getLogger("commander_ai_lab.overnight")
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
if logger.handlers:
|
||||
return logger
|
||||
|
||||
fmt = logging.Formatter(
|
||||
"%(asctime)s %(levelname)-5s [%(name)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setLevel(logging.INFO)
|
||||
console.setFormatter(fmt)
|
||||
logger.addHandler(console)
|
||||
|
||||
file_handler = logging.handlers.RotatingFileHandler(
|
||||
_LOG_DIR / "overnight-run.log",
|
||||
maxBytes=10 * 1024 * 1024, # 10 MB
|
||||
backupCount=5,
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(fmt)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
return logger
|
||||
|
||||
|
||||
log = setup_overnight_logging()
|
||||
def log(msg):
|
||||
ts = datetime.now().strftime("%H:%M:%S")
|
||||
print(f"[{ts}] {msg}")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def api_get(base_url, path):
|
||||
@@ -171,8 +129,8 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
while True:
|
||||
# Hard deadline check — stop waiting if we've run out of time
|
||||
if deadline and time.time() >= deadline:
|
||||
log.info(f" Time limit reached while batch {batch_id} was running.")
|
||||
log.info(f" The batch will continue on the server but we're moving on.")
|
||||
log(f" Time limit reached while batch {batch_id} was running.")
|
||||
log(f" The batch will continue on the server but we're moving on.")
|
||||
return {"completed": 0, "timed_out": True}
|
||||
|
||||
try:
|
||||
@@ -186,12 +144,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
elapsed = status.get("elapsedMs", 0)
|
||||
|
||||
if error:
|
||||
log.error(f" Batch {batch_id} ERROR: {error}")
|
||||
log(f" Batch {batch_id} ERROR: {error}")
|
||||
return None
|
||||
|
||||
if not running and completed > 0:
|
||||
elapsed_s = elapsed / 1000 if elapsed else 0
|
||||
log.info(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
|
||||
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
|
||||
try:
|
||||
result = api_get(base_url, f"/api/lab/result?batchId={batch_id}")
|
||||
return result
|
||||
@@ -202,15 +160,15 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
if not running and completed == 0:
|
||||
stall_checks += 1
|
||||
if stall_checks > 20: # 5 min with no progress
|
||||
log.info(f" Batch {batch_id} never started. Aborting.")
|
||||
log(f" Batch {batch_id} never started. Aborting.")
|
||||
return None
|
||||
|
||||
# Progress log with ETA
|
||||
if deadline:
|
||||
mins_left = (deadline - time.time()) / 60
|
||||
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
|
||||
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
|
||||
else:
|
||||
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
|
||||
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
|
||||
|
||||
# Track stall
|
||||
if completed == last_completed:
|
||||
@@ -222,9 +180,9 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
|
||||
except Exception as e:
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors > 10:
|
||||
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
|
||||
log(f" Lost connection to server after 10 retries. Aborting batch.")
|
||||
return None
|
||||
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
|
||||
log(f" Connection error (retry {consecutive_errors}/10): {e}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
@@ -256,26 +214,26 @@ def wait_for_training(base_url):
|
||||
error = status.get("error")
|
||||
|
||||
if error:
|
||||
log.error(f" Training ERROR: {error}")
|
||||
log(f" Training ERROR: {error}")
|
||||
return False
|
||||
|
||||
if not running and phase in ("done", "idle") and epoch > 0:
|
||||
result = status.get("result", {})
|
||||
log.info(f" Training complete!")
|
||||
log(f" Training complete!")
|
||||
if result:
|
||||
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
|
||||
log.info(f" Model saved: {result.get('model_path', '?')}")
|
||||
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
|
||||
log(f" Model saved: {result.get('model_path', '?')}")
|
||||
return True
|
||||
|
||||
if phase == "training" and total > 0:
|
||||
metrics = status.get("metrics", {})
|
||||
val_acc = metrics.get("val_accuracy", "?") if metrics else "?"
|
||||
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
|
||||
log(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
|
||||
else:
|
||||
log.info(f" [{phase}] {message}")
|
||||
log(f" [{phase}] {message}")
|
||||
|
||||
except Exception as e:
|
||||
log.info(f" Connection error: {e}")
|
||||
log(f" Connection error: {e}")
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
@@ -295,46 +253,46 @@ def run_overnight(cfg):
|
||||
"""Main overnight pipeline."""
|
||||
base_url = cfg["lab_url"]
|
||||
|
||||
log.info("=" * 60)
|
||||
log.info(" Commander AI Lab — Overnight AFK Runner")
|
||||
log.info("=" * 60)
|
||||
log("=" * 60)
|
||||
log(" Commander AI Lab — Overnight AFK Runner")
|
||||
log("=" * 60)
|
||||
|
||||
# ── Verify server ────────────────────────────────────────
|
||||
log.info("Checking lab server...")
|
||||
log("Checking lab server...")
|
||||
if not check_server(base_url):
|
||||
log.error(f"ERROR: Cannot reach lab server at {base_url}")
|
||||
log.info("Start the lab first: start-lab.bat")
|
||||
log(f"ERROR: Cannot reach lab server at {base_url}")
|
||||
log("Start the lab first: start-lab.bat")
|
||||
return False
|
||||
|
||||
log.info(f"Server OK at {base_url}")
|
||||
log(f"Server OK at {base_url}")
|
||||
|
||||
# ── Get decks ─────────────────────────────────────────────
|
||||
available_decks = get_available_decks(base_url)
|
||||
log.info(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
|
||||
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
|
||||
|
||||
engine = cfg["engine"]
|
||||
if engine == "deepseek":
|
||||
decks = cfg["ds_decks"] if cfg["ds_decks"] else available_decks
|
||||
if not decks:
|
||||
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
|
||||
log("ERROR: No decks available. Create decks in the Deck Builder first.")
|
||||
return False
|
||||
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
|
||||
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
|
||||
else:
|
||||
decks = cfg["java_decks"]
|
||||
if len(decks) != 3:
|
||||
log.error("ERROR: Java engine requires exactly 3 deck names.")
|
||||
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
|
||||
log("ERROR: Java engine requires exactly 3 deck names.")
|
||||
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
|
||||
return False
|
||||
log.info(f"Using Java/Forge engine with decks: {decks}")
|
||||
log(f"Using Java/Forge engine with decks: {decks}")
|
||||
|
||||
# ── ML logging ────────────────────────────────────────────
|
||||
log.info("Enabling ML logging...")
|
||||
log("Enabling ML logging...")
|
||||
try:
|
||||
api_post(base_url, "/api/ml/toggle?enable=true", {})
|
||||
log.info("ML logging enabled")
|
||||
log("ML logging enabled")
|
||||
except Exception as e:
|
||||
log.warning(f"WARNING: Could not enable ML logging: {e}")
|
||||
log.info("(DeepSeek batches always log ML data regardless)")
|
||||
log(f"WARNING: Could not enable ML logging: {e}")
|
||||
log("(DeepSeek batches always log ML data regardless)")
|
||||
# Try alternate form
|
||||
try:
|
||||
api_get(base_url, "/api/ml/toggle?enable=true")
|
||||
@@ -343,7 +301,7 @@ def run_overnight(cfg):
|
||||
|
||||
# ── Initial data stats ────────────────────────────────────
|
||||
decisions_before, files_before = get_ml_data_summary(base_url)
|
||||
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
|
||||
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
|
||||
|
||||
# ── Run batches ───────────────────────────────────────────
|
||||
games_per_batch = cfg["games_per_batch"]
|
||||
@@ -357,32 +315,32 @@ def run_overnight(cfg):
|
||||
total_games = 0
|
||||
total_decisions = 0
|
||||
|
||||
log.info("")
|
||||
log.info(f"Starting batch loop:")
|
||||
log("")
|
||||
log(f"Starting batch loop:")
|
||||
if num_batches > 0:
|
||||
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
|
||||
log(f" Mode: {num_batches} batches × {games_per_batch} games")
|
||||
else:
|
||||
log.info(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log.info(f" Engine: {engine}")
|
||||
log.info(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log.info("")
|
||||
log(f" Mode: Run for {cfg['time_limit_hours']}h, {games_per_batch} games/batch")
|
||||
log(f" Engine: {engine}")
|
||||
log(f" Auto-train after: {'Yes' if cfg['auto_train'] else 'No'}")
|
||||
log("")
|
||||
|
||||
while True:
|
||||
# Check stopping conditions
|
||||
if num_batches > 0 and batch_num >= num_batches:
|
||||
log.info(f"Reached target of {num_batches} batches. Stopping.")
|
||||
log(f"Reached target of {num_batches} batches. Stopping.")
|
||||
break
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
remaining = deadline - time.time()
|
||||
if remaining < 120: # need at least 2 min for a batch
|
||||
log.info(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
|
||||
log(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
|
||||
break
|
||||
|
||||
batch_num += 1
|
||||
elapsed_str = str(timedelta(seconds=int(elapsed)))
|
||||
remaining_str = str(timedelta(seconds=int(remaining)))
|
||||
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
|
||||
log(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
|
||||
|
||||
try:
|
||||
if engine == "deepseek":
|
||||
@@ -394,12 +352,12 @@ def run_overnight(cfg):
|
||||
)
|
||||
|
||||
if not batch_id:
|
||||
log.error(" ERROR: Failed to start batch (no batchId returned)")
|
||||
log.info(" Waiting 60s before retry...")
|
||||
log(" ERROR: Failed to start batch (no batchId returned)")
|
||||
log(" Waiting 60s before retry...")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
log.info(f" Started batch {batch_id}")
|
||||
log(f" Started batch {batch_id}")
|
||||
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
|
||||
|
||||
if result:
|
||||
@@ -408,19 +366,19 @@ def run_overnight(cfg):
|
||||
for deck in result.get("decks", []):
|
||||
wr = deck.get("winRate", "?")
|
||||
name = deck.get("deckName", "?")
|
||||
log.info(f" {name}: {wr}% WR")
|
||||
log(f" {name}: {wr}% WR")
|
||||
else:
|
||||
log.info(" Batch failed, continuing to next...")
|
||||
log(" Batch failed, continuing to next...")
|
||||
|
||||
except Exception as e:
|
||||
log.error(f" ERROR starting batch: {e}")
|
||||
log.info(" Waiting 60s before retry...")
|
||||
log(f" ERROR starting batch: {e}")
|
||||
log(" Waiting 60s before retry...")
|
||||
time.sleep(60)
|
||||
continue
|
||||
|
||||
# Pause between batches
|
||||
if pause_sec > 0:
|
||||
log.info(f" Cooling down {pause_sec}s...")
|
||||
log(f" Cooling down {pause_sec}s...")
|
||||
time.sleep(pause_sec)
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────
|
||||
@@ -429,26 +387,26 @@ def run_overnight(cfg):
|
||||
new_decisions = decisions_after - decisions_before
|
||||
new_files = files_after - files_before
|
||||
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info(" BATCH SIMULATION COMPLETE")
|
||||
log.info("=" * 60)
|
||||
log.info(f" Batches run: {batch_num}")
|
||||
log.info(f" Total games: ~{total_games}")
|
||||
log.info(f" New ML decisions: {new_decisions} (in {new_files} new files)")
|
||||
log.info(f" Total ML data: {decisions_after} decisions")
|
||||
log.info(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log.info("")
|
||||
log("")
|
||||
log("=" * 60)
|
||||
log(" BATCH SIMULATION COMPLETE")
|
||||
log("=" * 60)
|
||||
log(f" Batches run: {batch_num}")
|
||||
log(f" Total games: ~{total_games}")
|
||||
log(f" New ML decisions: {new_decisions} (in {new_files} new files)")
|
||||
log(f" Total ML data: {decisions_after} decisions")
|
||||
log(f" Elapsed time: {str(timedelta(seconds=int(total_elapsed)))}")
|
||||
log("")
|
||||
|
||||
# ── Auto-train ────────────────────────────────────────────
|
||||
if cfg["auto_train"]:
|
||||
if decisions_after < 100:
|
||||
log.info("Skipping training — too few decisions (need at least 100)")
|
||||
log("Skipping training — too few decisions (need at least 100)")
|
||||
return True
|
||||
|
||||
log.info("Starting ML training pipeline...")
|
||||
log.info(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
||||
log("Starting ML training pipeline...")
|
||||
log(f" Epochs: {cfg['train_epochs']}, LR: {cfg['train_lr']}, "
|
||||
f"Batch: {cfg['train_batch_size']}, Patience: {cfg['train_patience']}")
|
||||
|
||||
try:
|
||||
start_training(
|
||||
@@ -458,21 +416,21 @@ def run_overnight(cfg):
|
||||
batch_size=cfg["train_batch_size"],
|
||||
patience=cfg["train_patience"],
|
||||
)
|
||||
log.info("Training started, monitoring progress...")
|
||||
log("Training started, monitoring progress...")
|
||||
success = wait_for_training(base_url)
|
||||
if success:
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info(" TRAINING COMPLETE — model updated!")
|
||||
log.info("=" * 60)
|
||||
log("")
|
||||
log("=" * 60)
|
||||
log(" TRAINING COMPLETE — model updated!")
|
||||
log("=" * 60)
|
||||
else:
|
||||
log.info("Training finished with errors. Check the Training tab for details.")
|
||||
log("Training finished with errors. Check the Training tab for details.")
|
||||
except Exception as e:
|
||||
log.error(f"ERROR starting training: {e}")
|
||||
log(f"ERROR starting training: {e}")
|
||||
return False
|
||||
|
||||
log.info("")
|
||||
log.info("Overnight run finished. You can close this window.")
|
||||
log("")
|
||||
log("Overnight run finished. You can close this window.")
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -10,30 +10,8 @@ readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.100.0",
|
||||
"uvicorn[standard]>=0.23.0",
|
||||
"pydantic>=2.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-asyncio>=0.21",
|
||||
"httpx>=0.24.0",
|
||||
"ruff>=0.1.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
||||
[project.scripts]
|
||||
commander-sim = "commander_ai_lab.lab.experiments:_cli_main"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = [".", "src"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 120
|
||||
target-version = "py310"
|
||||
@@ -1,7 +0,0 @@
|
||||
# Core runtime dependencies
|
||||
# Install with: pip install -r requirements.txt
|
||||
# Or for dev tools: pip install -e ".[dev]"
|
||||
|
||||
fastapi>=0.100.0
|
||||
uvicorn[standard]>=0.23.0
|
||||
pydantic>=2.0.0
|
||||
+2
-6
@@ -13,10 +13,6 @@ No local OCR or OpenCV needed — all recognition is done by the
|
||||
Ximilar cloud API using visual AI.
|
||||
"""
|
||||
from typing import Callable, Optional
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.scanner")
|
||||
|
||||
|
||||
from .ximilar_client import identify_cards, XimilarResult
|
||||
|
||||
@@ -164,7 +160,7 @@ def _ximilar_to_scan_result(xr: XimilarResult,
|
||||
try:
|
||||
scryfall_data = scryfall_lookup(xr.name)
|
||||
except Exception as e:
|
||||
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
|
||||
print(f" [SCAN] Scryfall lookup failed for '{xr.name}': {e}")
|
||||
|
||||
if scryfall_data and scryfall_data.get("object") != "error":
|
||||
result.scryfall_id = scryfall_data.get("id", "")
|
||||
@@ -185,6 +181,6 @@ def _ximilar_to_scan_result(xr: XimilarResult,
|
||||
# Extract image from scryfall URL pattern
|
||||
# e.g., https://scryfall.com/card/m11/149/lightning-bolt
|
||||
result.image_uri = "" # Will be fetched by frontend via scryfall_id
|
||||
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
|
||||
print(f" [SCAN] No Scryfall match for '{xr.name}' — using Ximilar data only")
|
||||
|
||||
return result
|
||||
@@ -13,10 +13,6 @@ import json
|
||||
from typing import Optional, List
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError, HTTPError
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.scanner.ximilar")
|
||||
|
||||
|
||||
XIMILAR_ENDPOINT = "https://api.ximilar.com/collectibles/v2/tcg_id"
|
||||
|
||||
@@ -86,7 +82,7 @@ def identify_cards(image_bytes: bytes, api_key: str,
|
||||
)
|
||||
|
||||
try:
|
||||
log.info(f" Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
|
||||
print(f" [XIMILAR] Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
|
||||
with urlopen(req, timeout=60) as resp:
|
||||
resp_data = json.loads(resp.read().decode("utf-8"))
|
||||
except HTTPError as e:
|
||||
@@ -172,12 +168,12 @@ def _parse_response(resp_data: dict) -> List[XimilarResult]:
|
||||
if not results:
|
||||
results.append(XimilarResult(error="Ximilar could not identify any cards"))
|
||||
|
||||
log.info(f" Identified {len(results)} card(s)")
|
||||
print(f" [XIMILAR] Identified {len(results)} card(s)")
|
||||
for r in results:
|
||||
if r.name:
|
||||
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
|
||||
print(f" [XIMILAR] - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
|
||||
elif r.error:
|
||||
log.error(f" - ERROR: {r.error}")
|
||||
print(f" [XIMILAR] - ERROR: {r.error}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -13,10 +13,6 @@ Functions:
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("commander_ai_lab.lab.experiments")
|
||||
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
@@ -242,27 +238,27 @@ def _generate_training_deck() -> list[Card]:
|
||||
|
||||
def _print_stats(stats: dict) -> None:
|
||||
"""Pretty-print simulation stats."""
|
||||
log.info("")
|
||||
log.info("=" * 60)
|
||||
log.info(f" SIMULATION RESULTS: {stats['deck_name']} vs {stats['opponent_name']}")
|
||||
log.info("=" * 60)
|
||||
log.info(f" Games played: {stats['total_games']}")
|
||||
log.info(f" Wins / Losses: {stats['wins']} / {stats['losses']}")
|
||||
log.info(f" Win Rate: {stats['win_rate']}%")
|
||||
log.info(f" Avg Turns: {stats['avg_turns']}")
|
||||
log.info("-" * 60)
|
||||
log.info(f" Avg Damage Dealt: {stats['avg_damage_dealt']}")
|
||||
log.info(f" Avg Damage Received: {stats['avg_damage_received']}")
|
||||
log.info(f" Avg Spells Cast: {stats['avg_spells_cast']}")
|
||||
log.info(f" Avg Creatures Played: {stats['avg_creatures_played']}")
|
||||
log.info(f" Avg Removal Used: {stats['avg_removal_used']}")
|
||||
log.info(f" Avg Ramp Played: {stats['avg_ramp_played']}")
|
||||
log.info(f" Avg Cards Drawn: {stats['avg_cards_drawn']}")
|
||||
log.info(f" Avg Max Board Size: {stats['avg_max_board_size']}")
|
||||
log.info("-" * 60)
|
||||
log.info(f" Elapsed: {stats['elapsed_seconds']}s")
|
||||
log.info("=" * 60)
|
||||
log.info("")
|
||||
print()
|
||||
print("=" * 60)
|
||||
print(f" SIMULATION RESULTS: {stats['deck_name']} vs {stats['opponent_name']}")
|
||||
print("=" * 60)
|
||||
print(f" Games played: {stats['total_games']}")
|
||||
print(f" Wins / Losses: {stats['wins']} / {stats['losses']}")
|
||||
print(f" Win Rate: {stats['win_rate']}%")
|
||||
print(f" Avg Turns: {stats['avg_turns']}")
|
||||
print("-" * 60)
|
||||
print(f" Avg Damage Dealt: {stats['avg_damage_dealt']}")
|
||||
print(f" Avg Damage Received: {stats['avg_damage_received']}")
|
||||
print(f" Avg Spells Cast: {stats['avg_spells_cast']}")
|
||||
print(f" Avg Creatures Played: {stats['avg_creatures_played']}")
|
||||
print(f" Avg Removal Used: {stats['avg_removal_used']}")
|
||||
print(f" Avg Ramp Played: {stats['avg_ramp_played']}")
|
||||
print(f" Avg Cards Drawn: {stats['avg_cards_drawn']}")
|
||||
print(f" Avg Max Board Size: {stats['avg_max_board_size']}")
|
||||
print("-" * 60)
|
||||
print(f" Elapsed: {stats['elapsed_seconds']}s")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
|
||||
def _cli_main() -> None:
|
||||
@@ -298,15 +294,15 @@ def _run_smoke_test() -> None:
|
||||
"Harmonize", "Read the Bones", "Phyrexian Arena",
|
||||
]
|
||||
|
||||
log.info("Building test deck (Korvold Aristocrats)...")
|
||||
print("Building test deck (Korvold Aristocrats)...")
|
||||
test_deck = build_deck(test_decklist)
|
||||
log.info(f" Deck size: {len(test_deck)} cards")
|
||||
print(f" Deck size: {len(test_deck)} cards")
|
||||
|
||||
log.info("\nRunning single game...")
|
||||
print("\nRunning single game...")
|
||||
single = run_single_game(test_deck, name_a="Korvold")
|
||||
log.info(f" Winner: {single['winner_name']} in {single['turns']} turns")
|
||||
print(f" Winner: {single['winner_name']} in {single['turns']} turns")
|
||||
|
||||
log.info("\nRunning Monte Carlo simulation (10 games)...")
|
||||
print("\nRunning Monte Carlo simulation (10 games)...")
|
||||
stats = run_simulation(test_deck, num_games=10, name_a="Korvold")
|
||||
_print_stats(stats)
|
||||
|
||||
|
||||
@@ -18,8 +18,6 @@ Performance features:
|
||||
- Response caching for identical game states
|
||||
- Structured JSON output with validation
|
||||
- Decision logging (JSONL) for future RL training data
|
||||
Each JSONL entry includes game_result (winner_seat, winner_name,
|
||||
player_lives, reward) once flush_log() is called with a GameResult.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -117,7 +115,7 @@ def build_game_state_snapshot(
|
||||
opp = sim_state.players[1 - player_index]
|
||||
ctx = deck_context or {}
|
||||
|
||||
# ── Mana by Color (Enhancement #8) ────────────────────────────
|
||||
# ── Mana by Color (Enhancement #8) ──────────────────────────
|
||||
mana_total = 0
|
||||
mana_by_color = {"W": 0, "U": 0, "B": 0, "R": 0, "G": 0, "C": 0}
|
||||
my_bf = sim_state.get_battlefield(player_index)
|
||||
@@ -129,6 +127,7 @@ def build_game_state_snapshot(
|
||||
if land_name in _LAND_COLOR_MAP:
|
||||
mana_by_color[_LAND_COLOR_MAP[land_name]] += 1
|
||||
elif c.oracle_text:
|
||||
# Parse oracle text for mana production
|
||||
oracle_lower = c.oracle_text.lower()
|
||||
if "any color" in oracle_lower:
|
||||
for clr in "WUBRG":
|
||||
@@ -139,13 +138,14 @@ def build_game_state_snapshot(
|
||||
if sym in oracle_lower or f"add {clr.lower()}" in oracle_lower:
|
||||
mana_by_color[clr] += 1
|
||||
if not any(mana_by_color[k] for k in "WUBRG"):
|
||||
mana_by_color["C"] += 1
|
||||
mana_by_color["C"] += 1 # colorless
|
||||
else:
|
||||
mana_by_color["C"] += 1
|
||||
|
||||
# Only include colors that have sources
|
||||
mana_colors = {k: v for k, v in mana_by_color.items() if v > 0}
|
||||
|
||||
# ── My Hand — Full Oracle Text (Enhancement #5) ────────────────
|
||||
# ── My Hand — Full Oracle Text (Enhancement #5) ────────────
|
||||
my_hand = []
|
||||
for c in me.hand:
|
||||
card_info = {
|
||||
@@ -157,6 +157,7 @@ def build_game_state_snapshot(
|
||||
card_info["pt"] = c.pt
|
||||
if c.keywords:
|
||||
card_info["keywords"] = c.keywords[:5]
|
||||
# Role detection
|
||||
roles = []
|
||||
if c.is_removal:
|
||||
roles.append("removal")
|
||||
@@ -168,17 +169,22 @@ def build_game_state_snapshot(
|
||||
roles.append("commander")
|
||||
if roles:
|
||||
card_info["roles"] = roles
|
||||
# Full oracle text — no truncation (Enhancement #5)
|
||||
if c.oracle_text:
|
||||
card_info["oracle"] = c.oracle_text
|
||||
# Castability check against available mana
|
||||
card_info["castable"] = (c.cmc or 0) <= mana_total and not c.is_land()
|
||||
my_hand.append(card_info)
|
||||
|
||||
# ── My Board ──
|
||||
# ── My Board — with keywords and oracle for synergy ───────
|
||||
my_board = []
|
||||
for c in my_bf:
|
||||
if c.is_land():
|
||||
continue
|
||||
entry = {"name": c.name, "type": _short_type(c.type_line)}
|
||||
entry = {
|
||||
"name": c.name,
|
||||
"type": _short_type(c.type_line),
|
||||
}
|
||||
if c.pt:
|
||||
entry["pt"] = c.pt
|
||||
if c.tapped:
|
||||
@@ -189,7 +195,7 @@ def build_game_state_snapshot(
|
||||
entry["is_commander"] = True
|
||||
my_board.append(entry)
|
||||
|
||||
# ── Opponent's Board — with Threat Assessment (Enhancement #9) ──
|
||||
# ── Opponent's Board — with Threat Assessment (Enhancement #9)
|
||||
opp_board = []
|
||||
opp_total_power = 0
|
||||
opp_evasion_count = 0
|
||||
@@ -197,7 +203,10 @@ def build_game_state_snapshot(
|
||||
for c in opp_bf:
|
||||
if c.is_land():
|
||||
continue
|
||||
entry = {"name": c.name, "type": _short_type(c.type_line)}
|
||||
entry = {
|
||||
"name": c.name,
|
||||
"type": _short_type(c.type_line),
|
||||
}
|
||||
if c.pt:
|
||||
entry["pt"] = c.pt
|
||||
opp_total_power += c.get_power()
|
||||
@@ -212,6 +221,7 @@ def build_game_state_snapshot(
|
||||
opp_protected_count += 1
|
||||
opp_board.append(entry)
|
||||
|
||||
# Threat level summary
|
||||
if opp_total_power >= me.life:
|
||||
threat_level = "LETHAL"
|
||||
elif opp_total_power >= me.life * 0.5:
|
||||
@@ -223,7 +233,7 @@ def build_game_state_snapshot(
|
||||
else:
|
||||
threat_level = "NONE"
|
||||
|
||||
# ── Graveyard Contents (Enhancement #6) ──────────────────────
|
||||
# ── Graveyard Contents (Enhancement #6) ────────────────────
|
||||
my_gy = [{"name": c.name, "type": _short_type(c.type_line)} for c in me.graveyard[:15]]
|
||||
opp_gy = [{"name": c.name, "type": _short_type(c.type_line)} for c in opp.graveyard[:10]]
|
||||
|
||||
@@ -233,6 +243,7 @@ def build_game_state_snapshot(
|
||||
cards_remaining = len(me.library)
|
||||
pct_drawn = round(cards_seen / max(total_deck_size, 1) * 100)
|
||||
|
||||
# Key cards still in library (if we know the decklist)
|
||||
key_cards_in_library = []
|
||||
if ctx.get("key_cards"):
|
||||
library_names = {c.name.lower() for c in me.library}
|
||||
@@ -240,7 +251,7 @@ def build_game_state_snapshot(
|
||||
if kc.lower() in library_names:
|
||||
key_cards_in_library.append(kc)
|
||||
|
||||
# ── Build the snapshot ────────────────────────────────────
|
||||
# ── Build the snapshot ─────────────────────────────────────
|
||||
snapshot = {
|
||||
"turn": turn + 1,
|
||||
"my_life": me.life,
|
||||
@@ -263,9 +274,11 @@ def build_game_state_snapshot(
|
||||
"deck_drawn_pct": pct_drawn,
|
||||
}
|
||||
|
||||
# ── Commander Identity (Enhancement #2) ────────────────────
|
||||
if ctx.get("commander_name"):
|
||||
# Determine commander zone
|
||||
cmd_name_lower = ctx["commander_name"].lower()
|
||||
cmd_zone = "library"
|
||||
cmd_zone = "library" # default
|
||||
for c in me.hand:
|
||||
if c.name.lower() == cmd_name_lower:
|
||||
cmd_zone = "hand"
|
||||
@@ -278,18 +291,26 @@ def build_game_state_snapshot(
|
||||
if c.name.lower() == cmd_name_lower:
|
||||
cmd_zone = "graveyard"
|
||||
break
|
||||
|
||||
snapshot["commander"] = {
|
||||
"name": ctx["commander_name"],
|
||||
"zone": cmd_zone,
|
||||
"color_identity": ctx.get("color_identity", []),
|
||||
}
|
||||
|
||||
# ── Full Decklist Awareness (Enhancement #1) ───────────────
|
||||
if ctx.get("deck_summary"):
|
||||
snapshot["deck_summary"] = ctx["deck_summary"]
|
||||
|
||||
# ── Win Conditions & Combos (Enhancement #3) ───────────────
|
||||
if ctx.get("combo_pieces"):
|
||||
snapshot["combo_pieces"] = ctx["combo_pieces"]
|
||||
|
||||
# ── Key Cards in Library (Enhancement #7) ──────────────────
|
||||
if key_cards_in_library:
|
||||
snapshot["key_cards_in_library"] = key_cards_in_library[:10]
|
||||
|
||||
# ── Historical Win Rate (Enhancement #10) ──────────────────
|
||||
if ctx.get("win_rate") is not None:
|
||||
snapshot["historical_win_rate"] = ctx["win_rate"]
|
||||
|
||||
@@ -319,7 +340,7 @@ def _short_type(type_line: str) -> str:
|
||||
|
||||
|
||||
def build_deck_context(
|
||||
full_deck: list,
|
||||
full_deck: list, # list[Card] — the full original deck
|
||||
commander_name: str = "",
|
||||
color_identity: list[str] | None = None,
|
||||
archetype: str = "midrange",
|
||||
@@ -339,6 +360,7 @@ def build_deck_context(
|
||||
if win_rate is not None:
|
||||
ctx["win_rate"] = win_rate
|
||||
|
||||
# ── Deck Composition Summary (Enhancement #1) ──────────────
|
||||
type_counts: dict[str, int] = {}
|
||||
roles: dict[str, list[str]] = {"removal": [], "ramp": [], "board_wipe": [], "draw": []}
|
||||
key_cards: list[str] = []
|
||||
@@ -349,25 +371,32 @@ def build_deck_context(
|
||||
for card in full_deck:
|
||||
ctype = _short_type(card.type_line) if card.type_line else "?"
|
||||
type_counts[ctype] = type_counts.get(ctype, 0) + 1
|
||||
|
||||
if not card.is_land():
|
||||
total_cmc += card.cmc or 0
|
||||
non_land_count += 1
|
||||
|
||||
# Track roles
|
||||
if card.is_removal:
|
||||
roles["removal"].append(card.name)
|
||||
if card.is_board_wipe:
|
||||
roles["board_wipe"].append(card.name)
|
||||
if card.is_ramp:
|
||||
roles["ramp"].append(card.name)
|
||||
# Detect draw engines
|
||||
oracle = (card.oracle_text or "").lower()
|
||||
if re.search(r"draw .* cards?", oracle):
|
||||
roles["draw"].append(card.name)
|
||||
|
||||
# ── Win Conditions & Combos (Enhancement #3) ──────────
|
||||
for pattern, label in _COMBO_PATTERNS:
|
||||
if re.search(pattern, oracle):
|
||||
combo_pieces.append({"name": card.name, "role": label})
|
||||
key_cards.append(card.name)
|
||||
break
|
||||
break # one label per card
|
||||
|
||||
avg_cmc = round(total_cmc / max(non_land_count, 1), 1)
|
||||
|
||||
ctx["deck_summary"] = {
|
||||
"card_types": type_counts,
|
||||
"avg_cmc": avg_cmc,
|
||||
@@ -377,8 +406,10 @@ def build_deck_context(
|
||||
"draw_count": len(roles["draw"]),
|
||||
"archetype": archetype,
|
||||
}
|
||||
ctx["combo_pieces"] = combo_pieces[:15]
|
||||
|
||||
ctx["combo_pieces"] = combo_pieces[:15] # cap to save tokens
|
||||
ctx["key_cards"] = list(set(key_cards))[:15]
|
||||
|
||||
return ctx
|
||||
|
||||
|
||||
@@ -386,18 +417,20 @@ def build_deck_context(
|
||||
# Action Schema (Step 3)
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# Valid actions the LLM can return
|
||||
VALID_ACTIONS = [
|
||||
"play_land",
|
||||
"cast_creature",
|
||||
"cast_removal",
|
||||
"cast_board_wipe",
|
||||
"cast_ramp",
|
||||
"cast_spell",
|
||||
"attack_all",
|
||||
"attack_safe",
|
||||
"hold",
|
||||
"play_land", # Play a land from hand
|
||||
"cast_creature", # Cast a creature spell
|
||||
"cast_removal", # Cast targeted removal
|
||||
"cast_board_wipe", # Cast a board wipe
|
||||
"cast_ramp", # Cast a ramp spell
|
||||
"cast_spell", # Cast any other non-creature spell
|
||||
"attack_all", # Attack with all eligible creatures
|
||||
"attack_safe", # Attack only with evasive/large creatures
|
||||
"hold", # Pass / don't act (save mana, bluff)
|
||||
]
|
||||
|
||||
# Action schema for the LLM's JSON response
|
||||
ACTION_SCHEMA = {
|
||||
"action": "one of: " + ", ".join(VALID_ACTIONS),
|
||||
"target_card": "(optional) name of card to cast or target",
|
||||
@@ -496,19 +529,14 @@ class DeepSeekBrain:
|
||||
"""
|
||||
LLM-powered decision engine for the AI opponent.
|
||||
|
||||
Sends game state to a local DeepSeek/GPT-OSS model and parses the action response.
|
||||
Sends game state to a local DeepSeek model and parses the action response.
|
||||
Falls back to heuristic on timeout/error.
|
||||
|
||||
Decision log entries include game outcome once flush_log() is called
|
||||
with a GameResult, enabling downstream RL weight learning.
|
||||
"""
|
||||
|
||||
def __init__(self, config: DeepSeekConfig | None = None):
|
||||
self.config = config or DeepSeekConfig()
|
||||
self._cache: OrderedDict[str, dict] = OrderedDict()
|
||||
self._decision_log: list[dict] = []
|
||||
self._current_game_id: str = self._new_game_id()
|
||||
self._ai_player_index: int | None = None # set by caller if known
|
||||
self._total_calls = 0
|
||||
self._total_fallbacks = 0
|
||||
self._total_cache_hits = 0
|
||||
@@ -516,21 +544,6 @@ class DeepSeekBrain:
|
||||
self._connected = False
|
||||
self._log_file = None
|
||||
|
||||
@staticmethod
|
||||
def _new_game_id() -> str:
|
||||
"""Generate a short unique game ID for grouping decisions in the log."""
|
||||
return hashlib.md5(str(time.time()).encode()).hexdigest()[:12]
|
||||
|
||||
def new_game(self, ai_player_index: int | None = None) -> None:
|
||||
"""
|
||||
Signal the start of a new game.
|
||||
|
||||
Call this before each game so that log entries are grouped by game_id
|
||||
and the ai_player_index is recorded for reward calculation.
|
||||
"""
|
||||
self._current_game_id = self._new_game_id()
|
||||
self._ai_player_index = ai_player_index
|
||||
|
||||
def check_connection(self) -> bool:
|
||||
"""Test if the LLM endpoint is reachable."""
|
||||
try:
|
||||
@@ -542,6 +555,7 @@ class DeepSeekBrain:
|
||||
self._connected = True
|
||||
models = data.get("data", [])
|
||||
if models:
|
||||
# Auto-detect model name from LM Studio
|
||||
model_id = models[0].get("id", self.config.model)
|
||||
if model_id:
|
||||
self.config.model = model_id
|
||||
@@ -555,20 +569,20 @@ class DeepSeekBrain:
|
||||
|
||||
def choose_action(
|
||||
self,
|
||||
sim_state,
|
||||
player_index: int,
|
||||
sim_state, # SimState
|
||||
player_index: int, # Which player is the AI
|
||||
turn: int,
|
||||
available_mana: int | None = None,
|
||||
deck_context: dict | None = None,
|
||||
deck_context: dict | None = None, # Full deck intelligence from build_deck_context()
|
||||
) -> dict:
|
||||
"""
|
||||
Choose an action for the AI player.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"action": str,
|
||||
"target_card": str,
|
||||
"reasoning": str,
|
||||
"action": str, # One of VALID_ACTIONS
|
||||
"target_card": str, # Optional card name
|
||||
"reasoning": str, # Brief explanation
|
||||
"source": "deepseek" | "heuristic" | "cache",
|
||||
"latency_ms": float,
|
||||
}
|
||||
@@ -576,10 +590,12 @@ class DeepSeekBrain:
|
||||
self._total_calls += 1
|
||||
t_start = time.time()
|
||||
|
||||
# Build game state snapshot with deck intelligence
|
||||
snapshot = build_game_state_snapshot(sim_state, player_index, turn, deck_context=deck_context)
|
||||
if available_mana is not None:
|
||||
snapshot["my_mana_available"] = available_mana
|
||||
|
||||
# Check cache
|
||||
if self.config.cache_enabled:
|
||||
cache_key = self._cache_key(snapshot)
|
||||
if cache_key in self._cache:
|
||||
@@ -589,6 +605,7 @@ class DeepSeekBrain:
|
||||
cached["latency_ms"] = round((time.time() - t_start) * 1000, 1)
|
||||
return cached
|
||||
|
||||
# Try LLM
|
||||
if not self._connected:
|
||||
return self._fallback_action(snapshot, t_start, "not_connected")
|
||||
|
||||
@@ -597,11 +614,13 @@ class DeepSeekBrain:
|
||||
result["source"] = "deepseek"
|
||||
result["latency_ms"] = round((time.time() - t_start) * 1000, 1)
|
||||
|
||||
# Cache the result
|
||||
if self.config.cache_enabled:
|
||||
self._put_cache(cache_key, result)
|
||||
|
||||
# Log decision
|
||||
if self.config.log_decisions:
|
||||
self._log_decision(snapshot, result, player_index)
|
||||
self._log_decision(snapshot, result)
|
||||
|
||||
self._total_latency_ms += result["latency_ms"]
|
||||
return result
|
||||
@@ -614,9 +633,11 @@ class DeepSeekBrain:
|
||||
|
||||
def _call_llm(self, snapshot: dict) -> dict:
|
||||
"""Send game state to LLM and parse response."""
|
||||
# Build the prompt
|
||||
game_state_json = json.dumps(snapshot, indent=2)
|
||||
user_prompt = USER_PROMPT_TEMPLATE.format(game_state_json=game_state_json)
|
||||
|
||||
# Build OpenAI-compatible request
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"messages": [
|
||||
@@ -637,6 +658,7 @@ class DeepSeekBrain:
|
||||
with urlopen(req, timeout=self.config.request_timeout) as resp:
|
||||
resp_data = json.loads(resp.read())
|
||||
|
||||
# Extract the response text
|
||||
choices = resp_data.get("choices", [])
|
||||
if not choices:
|
||||
raise ValueError("Empty response from LLM")
|
||||
@@ -650,12 +672,18 @@ class DeepSeekBrain:
|
||||
Handles common LLM quirks (markdown fences, extra text, etc.)
|
||||
"""
|
||||
text = raw_text.strip()
|
||||
|
||||
# Strip <think>...</think> tags (DeepSeek-R1 reasoning)
|
||||
# Handle both closed tags and unclosed tags (truncated by max_tokens)
|
||||
text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
|
||||
text = re.sub(r'<think>.*', '', text, flags=re.DOTALL).strip()
|
||||
|
||||
# Strip markdown code fences
|
||||
text = re.sub(r'^```(?:json)?\s*', '', text)
|
||||
text = re.sub(r'\s*```$', '', text)
|
||||
text = text.strip()
|
||||
|
||||
# Try to find a JSON object in the text
|
||||
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
|
||||
if json_match:
|
||||
text = json_match.group(0)
|
||||
@@ -663,6 +691,7 @@ class DeepSeekBrain:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
# Last resort: try to extract action keyword
|
||||
for action in VALID_ACTIONS:
|
||||
if action in raw_text.lower():
|
||||
return {
|
||||
@@ -672,14 +701,16 @@ class DeepSeekBrain:
|
||||
}
|
||||
raise ValueError(f"Could not parse LLM response as JSON: {raw_text[:200]}")
|
||||
|
||||
# Validate action
|
||||
action = parsed.get("action", "hold")
|
||||
if action not in VALID_ACTIONS:
|
||||
# Fuzzy match
|
||||
for valid in VALID_ACTIONS:
|
||||
if valid in action.lower() or action.lower() in valid:
|
||||
action = valid
|
||||
break
|
||||
else:
|
||||
action = "hold"
|
||||
action = "hold" # safe default
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
@@ -697,15 +728,18 @@ class DeepSeekBrain:
|
||||
opp_board = snapshot.get("opp_board", [])
|
||||
turn = snapshot.get("turn", 1)
|
||||
|
||||
# Priority: land > ramp (early) > removal (if outnumbered) > creature > attack > hold
|
||||
action = "hold"
|
||||
target = ""
|
||||
reasoning = f"Fallback ({reason})"
|
||||
|
||||
# 1. Play a land if available
|
||||
lands_in_hand = [c for c in hand if c.get("type") == "land"]
|
||||
if lands_in_hand:
|
||||
action = "play_land"
|
||||
target = lands_in_hand[0]["name"]
|
||||
reasoning = "Play land first"
|
||||
# 2. Early game: prioritize ramp
|
||||
elif turn <= 4:
|
||||
ramp_cards = [c for c in hand if c.get("role") == "ramp" and c.get("cmc", 99) <= mana]
|
||||
if ramp_cards:
|
||||
@@ -714,31 +748,38 @@ class DeepSeekBrain:
|
||||
target = ramp_cards[0]["name"]
|
||||
reasoning = "Early ramp"
|
||||
else:
|
||||
# Play cheapest creature
|
||||
creatures = [c for c in hand if c.get("type") == "creature" and c.get("cmc", 99) <= mana]
|
||||
if creatures:
|
||||
creatures.sort(key=lambda c: c.get("cmc", 0))
|
||||
action = "cast_creature"
|
||||
target = creatures[0]["name"]
|
||||
reasoning = "Cheap creature development"
|
||||
# 3. If opponent has many creatures and we have board wipe
|
||||
elif len(opp_board) >= 3 and len(my_board) <= 1:
|
||||
wipes = [c for c in hand if c.get("role") == "board_wipe" and c.get("cmc", 99) <= mana]
|
||||
if wipes:
|
||||
action = "cast_board_wipe"
|
||||
target = wipes[0]["name"]
|
||||
reasoning = "Opponent has board advantage, wiping"
|
||||
# 4. If opponent has a big threat, use removal
|
||||
elif opp_board:
|
||||
removal = [c for c in hand if c.get("role") == "removal" and c.get("cmc", 99) <= mana]
|
||||
if removal:
|
||||
action = "cast_removal"
|
||||
target = removal[0]["name"]
|
||||
reasoning = "Remove opponent threat"
|
||||
# 5. Play best creature
|
||||
if action == "hold":
|
||||
castable = [c for c in hand if c.get("type") == "creature" and c.get("cmc", 99) <= mana]
|
||||
if castable:
|
||||
# Pick the one with highest power
|
||||
castable.sort(key=lambda c: -int(c.get("pt", "0/0").split("/")[0]) if c.get("pt") else 0)
|
||||
action = "cast_creature"
|
||||
target = castable[0]["name"]
|
||||
reasoning = "Play threat"
|
||||
|
||||
# 6. Attack if we have creatures on board
|
||||
if action == "hold" and my_board:
|
||||
creatures_on_board = [c for c in my_board if c.get("type") == "creature" and not c.get("tapped")]
|
||||
if creatures_on_board:
|
||||
@@ -761,10 +802,13 @@ class DeepSeekBrain:
|
||||
# ── Cache helpers ──
|
||||
|
||||
def _cache_key(self, snapshot: dict) -> str:
|
||||
"""Create a deterministic cache key from game state."""
|
||||
# Normalize — exclude things that don't affect decisions
|
||||
key_data = json.dumps(snapshot, sort_keys=True)
|
||||
return hashlib.md5(key_data.encode()).hexdigest()
|
||||
|
||||
def _put_cache(self, key: str, value: dict):
|
||||
"""LRU cache insert."""
|
||||
if key in self._cache:
|
||||
self._cache.move_to_end(key)
|
||||
self._cache[key] = value
|
||||
@@ -773,17 +817,10 @@ class DeepSeekBrain:
|
||||
|
||||
# ── Decision logging ──
|
||||
|
||||
def _log_decision(self, snapshot: dict, result: dict, player_index: int | None = None) -> None:
|
||||
"""
|
||||
Append a decision entry to the in-memory log.
|
||||
|
||||
The entry is tagged with game_id so flush_log() can backfill
|
||||
game_result fields once the game is complete.
|
||||
"""
|
||||
def _log_decision(self, snapshot: dict, result: dict):
|
||||
"""Log decision for future training data."""
|
||||
entry = {
|
||||
"timestamp": time.time(),
|
||||
"game_id": self._current_game_id,
|
||||
"player_index": player_index if player_index is not None else self._ai_player_index,
|
||||
"game_state": snapshot,
|
||||
"decision": {
|
||||
"action": result.get("action"),
|
||||
@@ -792,96 +829,14 @@ class DeepSeekBrain:
|
||||
"source": result.get("source"),
|
||||
"latency_ms": result.get("latency_ms"),
|
||||
},
|
||||
# game_result is None until flush_log() is called with a GameResult
|
||||
"game_result": None,
|
||||
}
|
||||
self._decision_log.append(entry)
|
||||
|
||||
def flush_log(
|
||||
self,
|
||||
filepath: str | None = None,
|
||||
game_result=None, # Optional[GameResult] from engine.py
|
||||
) -> str | None:
|
||||
"""
|
||||
Write accumulated decision log to a JSONL file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filepath : str, optional
|
||||
Destination file path. Defaults to logs/decisions/<timestamp>.jsonl
|
||||
next to the package root.
|
||||
game_result : GameResult, optional
|
||||
Result from GameEngine.run() / run_n(). When provided, every
|
||||
buffered entry is backfilled with:
|
||||
|
||||
``game_result`` dict containing:
|
||||
- ``winner_seat`` : int — seat index of the winner
|
||||
- ``winner_name`` : str — display name of the winning player
|
||||
- ``turns`` : int — total game length in turns
|
||||
- ``player_lives`` : dict — {seat_index: final_life} for all seats
|
||||
- ``reward`` : float — normalised reward in [-1.0, 1.0]
|
||||
for the AI player recorded in each entry
|
||||
(winner_life - loser_life) / 40.0
|
||||
|
||||
Returns the absolute path written, or None if the log was empty.
|
||||
"""
|
||||
def flush_log(self, filepath: str | None = None):
|
||||
"""Write accumulated decision log to JSONL file."""
|
||||
if not self._decision_log:
|
||||
return None
|
||||
return
|
||||
|
||||
# ── Build game_result payload from GameResult dataclass ──
|
||||
result_payload: dict | None = None
|
||||
if game_result is not None:
|
||||
try:
|
||||
winner_seat: int = game_result.winner_seat
|
||||
winner_name: str = game_result.players[winner_seat].name
|
||||
turns: int = game_result.turns
|
||||
|
||||
# Per-seat final life totals
|
||||
player_lives: dict[int, int] = {
|
||||
pr.seat_index: pr.life
|
||||
for pr in game_result.players
|
||||
}
|
||||
|
||||
result_payload = {
|
||||
"winner_seat": winner_seat,
|
||||
"winner_name": winner_name,
|
||||
"turns": turns,
|
||||
"player_lives": player_lives,
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Could not extract game_result fields: %s", exc)
|
||||
|
||||
# ── Backfill each entry with game_result + per-entry reward ──
|
||||
for entry in self._decision_log:
|
||||
if result_payload is None:
|
||||
entry["game_result"] = None
|
||||
continue
|
||||
|
||||
# Determine reward for the player who made this decision
|
||||
pi = entry.get("player_index")
|
||||
winner_seat = result_payload["winner_seat"]
|
||||
player_lives = result_payload["player_lives"]
|
||||
|
||||
if pi is not None and len(player_lives) >= 2:
|
||||
my_life = player_lives.get(pi, 0)
|
||||
# Sum of all opponents' final life totals
|
||||
opp_life_sum = sum(
|
||||
v for k, v in player_lives.items() if k != pi
|
||||
)
|
||||
opp_count = max(len(player_lives) - 1, 1)
|
||||
avg_opp_life = opp_life_sum / opp_count
|
||||
raw_reward = (my_life - avg_opp_life) / 40.0
|
||||
reward = round(max(-1.0, min(1.0, raw_reward)), 4)
|
||||
else:
|
||||
# Fallback: binary win/loss
|
||||
reward = 1.0 if (pi is not None and pi == winner_seat) else -1.0
|
||||
|
||||
entry["game_result"] = {
|
||||
**result_payload,
|
||||
"reward": reward,
|
||||
}
|
||||
|
||||
# ── Write to JSONL ──
|
||||
if filepath is None:
|
||||
log_dir = self.config.log_dir or os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "..", "logs", "decisions"
|
||||
@@ -904,9 +859,7 @@ class DeepSeekBrain:
|
||||
def get_stats(self) -> dict:
|
||||
"""Return performance statistics."""
|
||||
avg_latency = (
|
||||
self._total_latency_ms / max(
|
||||
self._total_calls - self._total_fallbacks - self._total_cache_hits, 1
|
||||
)
|
||||
self._total_latency_ms / max(self._total_calls - self._total_fallbacks - self._total_cache_hits, 1)
|
||||
)
|
||||
return {
|
||||
"total_calls": self._total_calls,
|
||||
@@ -918,5 +871,4 @@ class DeepSeekBrain:
|
||||
"model": self.config.model,
|
||||
"api_base": self.config.api_base,
|
||||
"pending_log_entries": len(self._decision_log),
|
||||
"current_game_id": self._current_game_id,
|
||||
}
|
||||
@@ -5,16 +5,11 @@ Extends the headless GameEngine so that one player (the AI opponent)
|
||||
uses the DeepSeek LLM brain for decision-making instead of the static
|
||||
heuristic scorer.
|
||||
|
||||
N-player ready: accepts 2–4 decks. The AI-controlled seat uses DeepSeek;
|
||||
all other seats use the heuristic.
|
||||
|
||||
The LLM decides:
|
||||
1. Whether to play a land (and which one)
|
||||
2. Which spell(s) to cast (up to 2 per turn, same as base engine)
|
||||
3. Combat stance: attack_all, attack_safe, or hold
|
||||
|
||||
Multiplayer attack targeting uses the weakest-opponent heuristic.
|
||||
|
||||
If the LLM is unreachable or times out, falls back to the base-engine
|
||||
heuristic transparently.
|
||||
|
||||
@@ -82,40 +77,14 @@ class DeepSeekGameEngine:
|
||||
color_identity: list[str] | None = None,
|
||||
win_rate: float | None = None,
|
||||
) -> GameResult:
|
||||
"""Backward-compatible 2-player entry point."""
|
||||
return self.run_n(
|
||||
decks=[deck_a, deck_b],
|
||||
names=[name_a, name_b],
|
||||
game_id=game_id,
|
||||
archetype=archetype,
|
||||
commander_name=commander_name,
|
||||
color_identity=color_identity,
|
||||
win_rate=win_rate,
|
||||
)
|
||||
|
||||
def run_n(
|
||||
self,
|
||||
decks: list[list[Card]],
|
||||
names: list[str] | None = None,
|
||||
game_id: str | None = None,
|
||||
archetype: str = "midrange",
|
||||
commander_name: str = "",
|
||||
color_identity: list[str] | None = None,
|
||||
win_rate: float | None = None,
|
||||
) -> GameResult:
|
||||
"""Run a single N-player game. The AI player uses DeepSeek for decisions."""
|
||||
n = len(decks)
|
||||
if names is None:
|
||||
names = [f"Player {chr(65 + i)}" for i in range(n)]
|
||||
sim = self._create_state(decks, names)
|
||||
"""Run a single game. The AI player uses DeepSeek for decisions."""
|
||||
sim = self._create_state(deck_a, deck_b, name_a, name_b)
|
||||
game_log: list[dict] = []
|
||||
elimination_order: list[int] = []
|
||||
gid = game_id or str(uuid.uuid4())[:12]
|
||||
turn_decisions: list[dict] = []
|
||||
turn_decisions: list[dict] = [] # raw (snapshot, action_type) pairs
|
||||
|
||||
# Build deck intelligence context once at game start
|
||||
ai_idx = min(self.ai_player_index, n - 1)
|
||||
ai_deck = decks[ai_idx]
|
||||
ai_deck = deck_b if self.ai_player_index == 1 else deck_a
|
||||
self._deck_context = build_deck_context(
|
||||
full_deck=ai_deck,
|
||||
commander_name=commander_name,
|
||||
@@ -128,7 +97,7 @@ class DeepSeekGameEngine:
|
||||
for turn in range(sim.max_turns):
|
||||
turn_entry = {"turn": turn + 1, "phases": []}
|
||||
|
||||
for pi in range(len(sim.players)):
|
||||
for pi in range(2):
|
||||
p = sim.players[pi]
|
||||
if p.eliminated:
|
||||
continue
|
||||
@@ -149,7 +118,7 @@ class DeepSeekGameEngine:
|
||||
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
|
||||
|
||||
# ── Decide: LLM or Heuristic? ──
|
||||
action_type = "pass"
|
||||
action_type = "pass" # default
|
||||
if pi == self.ai_player_index and self.brain._connected:
|
||||
action_type = self._play_turn_deepseek(sim, pi, turn, phase_events, getattr(self, '_deck_context', None))
|
||||
else:
|
||||
@@ -172,29 +141,17 @@ class DeepSeekGameEngine:
|
||||
c.tapped = False
|
||||
|
||||
if self.record_log:
|
||||
life_dict = {sp.name: sp.life for sp in sim.players}
|
||||
boards_dict = {
|
||||
f"board_{si}": [c.name for c in sim.get_battlefield(si) if c.is_creature()]
|
||||
for si in range(len(sim.players))
|
||||
}
|
||||
phase_entry: dict = {
|
||||
turn_entry["phases"].append({
|
||||
"player": p.name,
|
||||
"playerId": pi,
|
||||
"events": phase_events,
|
||||
"lifeAfter": life_dict,
|
||||
**boards_dict,
|
||||
}
|
||||
if len(sim.players) == 2:
|
||||
phase_entry["boardA"] = boards_dict.get("board_0", [])
|
||||
phase_entry["boardB"] = boards_dict.get("board_1", [])
|
||||
turn_entry["phases"].append(phase_entry)
|
||||
|
||||
# ── Check for new eliminations ──
|
||||
for si, sp in enumerate(sim.players):
|
||||
if sp.eliminated and si not in elimination_order:
|
||||
elimination_order.append(si)
|
||||
if self.record_log:
|
||||
phase_events.append(f"{sp.name} eliminated!")
|
||||
"lifeAfter": {
|
||||
sim.players[0].name: sim.players[0].life,
|
||||
sim.players[1].name: sim.players[1].life,
|
||||
},
|
||||
"boardA": [c.name for c in sim.get_battlefield(0) if c.is_creature()],
|
||||
"boardB": [c.name for c in sim.get_battlefield(1) if c.is_creature()],
|
||||
})
|
||||
|
||||
# Check game over
|
||||
alive = [p for p in sim.players if not p.eliminated]
|
||||
@@ -202,16 +159,21 @@ class DeepSeekGameEngine:
|
||||
if self.record_log:
|
||||
game_log.append(turn_entry)
|
||||
if len(alive) <= 1:
|
||||
if self.record_log:
|
||||
elim_name = next((p.name for p in sim.players if p.eliminated), None)
|
||||
if elim_name:
|
||||
game_log[-1]["event"] = f"{elim_name} eliminated!"
|
||||
break
|
||||
if self.record_log:
|
||||
game_log.append(turn_entry) if turn_entry not in game_log else None
|
||||
|
||||
result = self._build_result(sim, final_turn, elimination_order)
|
||||
result = self._build_result(sim, final_turn, name_a, name_b)
|
||||
if self.record_log:
|
||||
result.game_log = game_log
|
||||
|
||||
# Stamp game outcome on all ML decisions and add to engine collection
|
||||
if self.ml_log and turn_decisions:
|
||||
ai_won = result.winner_seat == self.ai_player_index
|
||||
outcome = "win" if ai_won else "loss"
|
||||
outcome = "win" if result.winner == 0 else "loss" if result.winner == 1 else "draw"
|
||||
for td in turn_decisions:
|
||||
td["game_outcome"] = outcome
|
||||
self.ml_decisions.extend(turn_decisions)
|
||||
@@ -254,7 +216,7 @@ class DeepSeekGameEngine:
|
||||
|
||||
if self.record_log:
|
||||
events.append(f"[AI Brain: {source}] Action: {action} "
|
||||
f"{'\u2192 ' + target_card_name if target_card_name else ''} "
|
||||
f"{'→ ' + target_card_name if target_card_name else ''} "
|
||||
f"({reasoning})")
|
||||
|
||||
# ── Phase 1: Land drop ──
|
||||
@@ -280,7 +242,7 @@ class DeepSeekGameEngine:
|
||||
elif action == "hold":
|
||||
# LLM chose to hold — no combat
|
||||
if self.record_log:
|
||||
events.append("Holds \u2014 no attack")
|
||||
events.append("Holds — no attack")
|
||||
else:
|
||||
# Default: still attack if we have creatures and it makes sense
|
||||
self._resolve_combat_heuristic(sim, pi, turn, events)
|
||||
@@ -473,23 +435,21 @@ class DeepSeekGameEngine:
|
||||
p.stats.mana_spent += card.cmc or 0
|
||||
p.stats.spells_cast += 1
|
||||
|
||||
# Handle removal — target the scariest creature across all opponents
|
||||
# Handle removal
|
||||
if card.is_removal:
|
||||
p.stats.removal_used += 1
|
||||
all_opp_creatures = []
|
||||
for oi in range(len(sim.players)):
|
||||
if oi == pi or sim.players[oi].eliminated:
|
||||
continue
|
||||
for c in sim.get_battlefield(oi):
|
||||
if c.is_creature():
|
||||
all_opp_creatures.append(c)
|
||||
if all_opp_creatures:
|
||||
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = all_opp_creatures[0]
|
||||
opp_idx_r = 1 - pi
|
||||
opp_creatures = [
|
||||
c for c in sim.get_battlefield(opp_idx_r)
|
||||
if c.is_creature()
|
||||
]
|
||||
if opp_creatures:
|
||||
opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = opp_creatures[0]
|
||||
sim.remove_from_battlefield(killed.id)
|
||||
sim.players[killed.owner_id].graveyard.append(killed)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
|
||||
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
|
||||
else:
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal, no targets)")
|
||||
@@ -510,7 +470,7 @@ class DeepSeekGameEngine:
|
||||
sim.battlefields[seat_idx] = keep
|
||||
p.graveyard.append(card)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
|
||||
events.append(f"Cast {card.name} (board wipe) — destroyed {len(wiped_names)} creatures")
|
||||
|
||||
else:
|
||||
sim.add_to_battlefield(pi, card)
|
||||
@@ -528,23 +488,6 @@ class DeepSeekGameEngine:
|
||||
|
||||
return self._count_untapped_lands(sim, pi)
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Attack target selection (N-player)
|
||||
# ──────────────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
"""Select the weakest non-eliminated opponent (lowest life). Returns seat or -1."""
|
||||
best_seat = -1
|
||||
best_life = float('inf')
|
||||
for si, sp in enumerate(sim.players):
|
||||
if si == pi or sp.eliminated:
|
||||
continue
|
||||
if sp.life < best_life:
|
||||
best_life = sp.life
|
||||
best_seat = si
|
||||
return best_seat
|
||||
|
||||
# ──────────────────────────────────────────────────────────
|
||||
# Combat — DeepSeek directed
|
||||
# ──────────────────────────────────────────────────────────
|
||||
@@ -559,11 +502,12 @@ class DeepSeekGameEngine:
|
||||
attack_safe: only evasive/large creatures
|
||||
"""
|
||||
p = sim.players[pi]
|
||||
opp_idx = self._select_attack_target(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return
|
||||
opp_idx = 1 - pi
|
||||
opp = sim.players[opp_idx]
|
||||
|
||||
if opp.eliminated:
|
||||
return
|
||||
|
||||
my_creatures = [
|
||||
c for c in sim.get_battlefield(pi)
|
||||
if c.is_creature() and not c.tapped
|
||||
@@ -582,6 +526,7 @@ class DeepSeekGameEngine:
|
||||
if attack_mode == "attack_all":
|
||||
attackers = my_creatures[:]
|
||||
elif attack_mode == "attack_safe":
|
||||
# Only evasive (flying, trample, menace) or large (power >= 4)
|
||||
for atk in my_creatures:
|
||||
if (atk.has_keyword("flying") or atk.has_keyword("trample")
|
||||
or atk.has_keyword("menace") or atk.get_power() >= 4):
|
||||
@@ -593,7 +538,7 @@ class DeepSeekGameEngine:
|
||||
if events is not None and attackers:
|
||||
mode_label = "all-in" if attack_mode == "attack_all" else "safe"
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} ({mode_label}): {', '.join(atk_names)}")
|
||||
events.append(f"Attacks ({mode_label}): {', '.join(atk_names)}")
|
||||
|
||||
# Resolve blocking and damage (same logic as base engine)
|
||||
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
|
||||
@@ -601,13 +546,14 @@ class DeepSeekGameEngine:
|
||||
def _resolve_combat_heuristic(
|
||||
self, sim: SimState, pi: int, turn: int, events: list[str] | None
|
||||
) -> bool:
|
||||
"""Heuristic combat (N-player ready). Returns True if attacked."""
|
||||
"""Heuristic combat (same as base GameEngine._resolve_combat). Returns True if attacked."""
|
||||
p = sim.players[pi]
|
||||
opp_idx = self._select_attack_target(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return False
|
||||
opp_idx = 1 - pi
|
||||
opp = sim.players[opp_idx]
|
||||
|
||||
if opp.eliminated:
|
||||
return False
|
||||
|
||||
my_creatures = [
|
||||
c for c in sim.get_battlefield(pi)
|
||||
if c.is_creature() and not c.tapped
|
||||
@@ -650,7 +596,7 @@ class DeepSeekGameEngine:
|
||||
|
||||
if events is not None and attackers:
|
||||
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
|
||||
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
|
||||
events.append(f"Attacks with: {', '.join(atk_names)}")
|
||||
|
||||
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
|
||||
return len(attackers) > 0
|
||||
@@ -666,9 +612,7 @@ class DeepSeekGameEngine:
|
||||
):
|
||||
"""Resolve blocking and combat damage."""
|
||||
p = sim.players[pi]
|
||||
opp_idx = self._select_attack_target(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return
|
||||
opp_idx = 1 - pi
|
||||
opp = sim.players[opp_idx]
|
||||
w = self.weights
|
||||
|
||||
@@ -749,12 +693,12 @@ class DeepSeekGameEngine:
|
||||
def _capture_ml_snapshot(self, sim: SimState, pi: int, turn: int) -> dict:
|
||||
"""
|
||||
Capture the current game state in the format expected by the ML
|
||||
training pipeline (dataset_builder \u2192 state_encoder \u2192 labeler).
|
||||
training pipeline (dataset_builder → state_encoder → labeler).
|
||||
|
||||
Returns a dict compatible with ml-decisions-*.jsonl format.
|
||||
"""
|
||||
players_data = []
|
||||
for seat in range(len(sim.players)):
|
||||
for seat in range(2):
|
||||
p = sim.players[seat]
|
||||
# Count creatures and lands on battlefield
|
||||
seat_bf = sim.get_battlefield(seat)
|
||||
@@ -778,7 +722,7 @@ class DeepSeekGameEngine:
|
||||
c.name for c in seat_bf
|
||||
if not c.is_land()
|
||||
]
|
||||
# Command zone \u2014 not explicitly tracked in Python sim, use empty
|
||||
# Command zone — not explicitly tracked in Python sim, use empty
|
||||
command_zone_names = []
|
||||
|
||||
players_data.append({
|
||||
@@ -795,7 +739,7 @@ class DeepSeekGameEngine:
|
||||
"command_zone": command_zone_names,
|
||||
})
|
||||
|
||||
# Determine game phase \u2014 Python sim is simplified, treat as main_1
|
||||
# Determine game phase — Python sim is simplified, treat as main_1
|
||||
phase = "main_1"
|
||||
|
||||
return {
|
||||
@@ -844,12 +788,11 @@ class DeepSeekGameEngine:
|
||||
)
|
||||
|
||||
def _create_state(
|
||||
self, decks: list[list[Card]],
|
||||
names: list[str],
|
||||
self, deck_a: list[Card], deck_b: list[Card],
|
||||
name_a: str, name_b: str,
|
||||
) -> SimState:
|
||||
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
|
||||
sim = SimState(max_turns=self.max_turns)
|
||||
for idx, (deck, name) in enumerate(zip(decks, names)):
|
||||
for idx, (deck, name) in enumerate([(deck_a, name_a), (deck_b, name_b)]):
|
||||
cards = [enrich_card(c.clone()) for c in deck]
|
||||
random.shuffle(cards)
|
||||
hand = cards[:7]
|
||||
@@ -868,35 +811,17 @@ class DeepSeekGameEngine:
|
||||
|
||||
def _build_result(
|
||||
self, sim: SimState, final_turn: int,
|
||||
elimination_order: list[int] | None = None,
|
||||
name_a: str, name_b: str,
|
||||
) -> GameResult:
|
||||
"""Determine winner and build result object (N-player)."""
|
||||
if elimination_order is None:
|
||||
elimination_order = []
|
||||
n = len(sim.players)
|
||||
pa = sim.players[0]
|
||||
pb = sim.players[1]
|
||||
|
||||
alive = [i for i, p in enumerate(sim.players) if not p.eliminated]
|
||||
if len(alive) == 1:
|
||||
winner = alive[0]
|
||||
elif len(alive) == 0:
|
||||
winner = max(range(n), key=lambda i: sim.players[i].life)
|
||||
if pa.eliminated and not pb.eliminated:
|
||||
winner = 1
|
||||
elif not pa.eliminated and pb.eliminated:
|
||||
winner = 0
|
||||
else:
|
||||
winner = max(alive, key=lambda i: sim.players[i].life)
|
||||
|
||||
finish: dict[int, int] = {winner: 1}
|
||||
position = 2
|
||||
for seat in reversed(elimination_order):
|
||||
if seat != winner:
|
||||
finish[seat] = position
|
||||
position += 1
|
||||
remaining_alive = sorted(
|
||||
[i for i in alive if i != winner],
|
||||
key=lambda i: -sim.players[i].life,
|
||||
)
|
||||
for seat in remaining_alive:
|
||||
if seat not in finish:
|
||||
finish[seat] = position
|
||||
position += 1
|
||||
winner = 0 if pa.life >= pb.life else 1
|
||||
|
||||
player_results = []
|
||||
for seat, p in enumerate(sim.players):
|
||||
@@ -905,7 +830,7 @@ class DeepSeekGameEngine:
|
||||
name=p.name,
|
||||
life=p.life,
|
||||
eliminated=p.eliminated,
|
||||
finish_position=finish.get(seat, n),
|
||||
finish_position=1 if seat == winner else 2,
|
||||
stats=p.stats,
|
||||
))
|
||||
|
||||
|
||||
+172
-147
@@ -4,15 +4,13 @@ Commander AI Lab — Headless Game Engine
|
||||
A headless Commander game simulator for Monte Carlo analysis.
|
||||
Faithful port of dtSimGame() / dtCreatePlayer() from mtg-commander-lan.
|
||||
|
||||
N-player ready: accepts 2–4 decks and runs a full multiplayer game.
|
||||
Simulates simplified Commander games:
|
||||
Supports 2–4 player Commander pods (N-player).
|
||||
- Draw, land drop, spell casting (up to 2 per turn)
|
||||
- AI card scoring for play priority
|
||||
- Simplified combat with flying, trample, deathtouch, lifelink, menace, reach
|
||||
- Multiplayer attack targeting: weakest non-eliminated opponent heuristic
|
||||
- Creature removal and board wipe handling
|
||||
- Win by elimination (life <= 0) or life comparison at max turns
|
||||
- Elimination order tracking for N-player finish positions
|
||||
- Multiplayer threat targeting: attacks and removal target highest-threat opponent
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -37,15 +35,19 @@ from commander_ai_lab.sim.rules import (
|
||||
|
||||
class GameEngine:
|
||||
"""
|
||||
Headless Commander game engine (N-player ready).
|
||||
Headless Commander game engine — supports 2–4 player pods.
|
||||
|
||||
Usage::
|
||||
2-player usage (backward-compatible)::
|
||||
|
||||
engine = GameEngine(max_turns=25)
|
||||
# 2-player (backward-compatible)
|
||||
result = engine.run(deck_a, deck_b, name_a="Deck A", name_b="Deck B")
|
||||
# 4-player
|
||||
result = engine.run_n(decks=[d1, d2, d3, d4], names=["A","B","C","D"])
|
||||
|
||||
N-player usage::
|
||||
|
||||
result = engine.run_pod(
|
||||
decks=[deck_a, deck_b, deck_c, deck_d],
|
||||
names=["Alice", "Bob", "Carol", "Dave"],
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -71,27 +73,30 @@ class GameEngine:
|
||||
name_a: str = "Player A",
|
||||
name_b: str = "Player B",
|
||||
) -> GameResult:
|
||||
"""Backward-compatible 2-player entry point."""
|
||||
return self.run_n(
|
||||
decks=[deck_a, deck_b],
|
||||
names=[name_a, name_b],
|
||||
)
|
||||
"""Run a 1v1 headless game. Backward-compatible wrapper around run_pod()."""
|
||||
return self.run_pod(decks=[deck_a, deck_b], names=[name_a, name_b])
|
||||
|
||||
def run_n(
|
||||
def run_pod(
|
||||
self,
|
||||
decks: list[list[Card]],
|
||||
names: list[str] | None = None,
|
||||
names: Optional[list[str]] = None,
|
||||
) -> GameResult:
|
||||
"""Run a single headless N-player game and return the result."""
|
||||
n = len(decks)
|
||||
"""Run an N-player pod game (2–4 players) and return the result."""
|
||||
if not decks or len(decks) < 2:
|
||||
raise ValueError("run_pod requires at least 2 decks")
|
||||
if names is None:
|
||||
names = [f"Player {chr(65 + i)}" for i in range(n)]
|
||||
names = [f"Player {chr(65 + i)}" for i in range(len(decks))]
|
||||
if len(names) != len(decks):
|
||||
raise ValueError("len(names) must equal len(decks)")
|
||||
|
||||
sim = self._create_state(decks, names)
|
||||
game_log: list[dict] = []
|
||||
elimination_order: list[int] = [] # seats in order of elimination
|
||||
# Track elimination order: first eliminated = highest finish_position number
|
||||
sim.elimination_order: list[int] = [] # seat indices in elimination order
|
||||
|
||||
final_turn = 0
|
||||
for turn in range(sim.max_turns):
|
||||
sim.turn = turn
|
||||
turn_entry = {"turn": turn + 1, "phases": []}
|
||||
|
||||
for pi in range(len(sim.players)):
|
||||
@@ -103,7 +108,7 @@ class GameEngine:
|
||||
|
||||
# ── Draw ──
|
||||
if p.library:
|
||||
drawn = p.library[-1] # peek before pop
|
||||
drawn = p.library[-1]
|
||||
p.hand.append(p.library.pop())
|
||||
p.stats.cards_drawn += 1
|
||||
if self.record_log:
|
||||
@@ -114,8 +119,7 @@ class GameEngine:
|
||||
self._play_land(sim, pi)
|
||||
if self.record_log and p.stats.lands_played > land_before:
|
||||
last_land = next(
|
||||
(c for c in reversed(sim.get_battlefield(pi))
|
||||
if c.is_land()),
|
||||
(c for c in reversed(sim.get_battlefield(pi)) if c.is_land()),
|
||||
None,
|
||||
)
|
||||
if last_land:
|
||||
@@ -135,38 +139,31 @@ class GameEngine:
|
||||
# ── Combat ──
|
||||
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
|
||||
|
||||
# ── Check for new eliminations after combat ──
|
||||
for opp_idx, opp in enumerate(sim.players):
|
||||
if not opp.eliminated and opp.life <= 0:
|
||||
opp.eliminated = True
|
||||
sim.elimination_order.append(opp_idx)
|
||||
if self.record_log:
|
||||
phase_events.append(f"{opp.name} eliminated!")
|
||||
|
||||
# ── Untap ──
|
||||
for c in sim.get_battlefield(pi):
|
||||
c.tapped = False
|
||||
|
||||
if self.record_log:
|
||||
# N-player log: life dict + per-seat boards dict
|
||||
life_dict = {sp.name: sp.life for sp in sim.players}
|
||||
boards_dict = {
|
||||
f"board_{si}": [c.name for c in sim.get_battlefield(si) if c.is_creature()]
|
||||
for si in range(len(sim.players))
|
||||
}
|
||||
phase_entry: dict = {
|
||||
life_snapshot = {sim.players[s].name: sim.players[s].life
|
||||
for s in range(len(sim.players))}
|
||||
boards = {f"board_{s}": [c.name for c in sim.get_battlefield(s) if c.is_creature()]
|
||||
for s in range(len(sim.players))}
|
||||
turn_entry["phases"].append({
|
||||
"player": p.name,
|
||||
"playerId": pi,
|
||||
"events": phase_events,
|
||||
"lifeAfter": life_dict,
|
||||
**boards_dict,
|
||||
}
|
||||
# Legacy keys for 2-player UI compatibility
|
||||
if len(sim.players) == 2:
|
||||
phase_entry["boardA"] = boards_dict.get("board_0", [])
|
||||
phase_entry["boardB"] = boards_dict.get("board_1", [])
|
||||
turn_entry["phases"].append(phase_entry)
|
||||
"lifeAfter": life_snapshot,
|
||||
**boards,
|
||||
})
|
||||
|
||||
# ── Check for new eliminations after this player's turn ──
|
||||
for si, sp in enumerate(sim.players):
|
||||
if sp.eliminated and si not in elimination_order:
|
||||
elimination_order.append(si)
|
||||
if self.record_log:
|
||||
phase_events.append(f"{sp.name} eliminated!")
|
||||
|
||||
# Check game over
|
||||
alive = [p for p in sim.players if not p.eliminated]
|
||||
final_turn = turn + 1
|
||||
if self.record_log:
|
||||
@@ -174,7 +171,8 @@ class GameEngine:
|
||||
if len(alive) <= 1:
|
||||
break
|
||||
|
||||
result = self._build_result(sim, final_turn, elimination_order)
|
||||
# Mark any remaining alive players as not-eliminated (they survived)
|
||||
result = self._build_result(sim, final_turn)
|
||||
if self.record_log:
|
||||
result.game_log = game_log
|
||||
return result
|
||||
@@ -188,11 +186,10 @@ class GameEngine:
|
||||
decks: list[list[Card]],
|
||||
names: list[str],
|
||||
) -> SimState:
|
||||
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
|
||||
"""Create initial simulation state for N players."""
|
||||
sim = SimState(max_turns=self.max_turns)
|
||||
|
||||
for idx, (deck, name) in enumerate(zip(decks, names)):
|
||||
# Enrich + deep copy cards
|
||||
cards = [enrich_card(c.clone()) for c in deck]
|
||||
random.shuffle(cards)
|
||||
|
||||
@@ -239,12 +236,65 @@ class GameEngine:
|
||||
if not c.tapped and c.is_land()
|
||||
)
|
||||
|
||||
def _pick_removal_target_seat(self, sim: SimState, pi: int) -> int:
|
||||
"""Pick the best opponent seat to target with removal.
|
||||
|
||||
Returns the seat index of the alive opponent with the highest-value
|
||||
creature on the battlefield. Falls back to any alive opponent.
|
||||
"""
|
||||
w = self.weights
|
||||
best_seat = -1
|
||||
best_score = -1
|
||||
for opp_idx, opp in enumerate(sim.players):
|
||||
if opp_idx == pi or opp.eliminated:
|
||||
continue
|
||||
top_creature = max(
|
||||
(c for c in sim.get_battlefield(opp_idx) if c.is_creature()),
|
||||
key=lambda c: score_card(c, w),
|
||||
default=None,
|
||||
)
|
||||
if top_creature is not None:
|
||||
s = score_card(top_creature, w)
|
||||
if s > best_score:
|
||||
best_score = s
|
||||
best_seat = opp_idx
|
||||
if best_seat == -1:
|
||||
# No creatures anywhere — return first alive opponent
|
||||
for opp_idx, opp in enumerate(sim.players):
|
||||
if opp_idx != pi and not opp.eliminated:
|
||||
return opp_idx
|
||||
return best_seat
|
||||
|
||||
def _pick_attack_target_seat(self, sim: SimState, pi: int) -> int:
|
||||
"""Pick the best opponent seat to attack.
|
||||
|
||||
Targets the alive opponent with the highest total board power
|
||||
(i.e. biggest threat). If tied or no creatures, targets the
|
||||
opponent with the lowest life (most vulnerable).
|
||||
"""
|
||||
alive_opps = [
|
||||
(opp_idx, opp)
|
||||
for opp_idx, opp in enumerate(sim.players)
|
||||
if opp_idx != pi and not opp.eliminated
|
||||
]
|
||||
if not alive_opps:
|
||||
return -1
|
||||
# Score by: total opponent board power (threat) then lowest life (vulnerability)
|
||||
def threat_key(entry):
|
||||
opp_idx, opp = entry
|
||||
board_power = sum(
|
||||
c.get_power() for c in sim.get_battlefield(opp_idx) if c.is_creature()
|
||||
)
|
||||
return (board_power, -opp.life)
|
||||
|
||||
target_idx, _ = max(alive_opps, key=threat_key)
|
||||
return target_idx
|
||||
|
||||
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
|
||||
"""Play up to 2 spells from hand, prioritized by AI score."""
|
||||
p = sim.players[pi]
|
||||
w = self.weights
|
||||
|
||||
# Score playable non-land cards
|
||||
playable = []
|
||||
for i, card in enumerate(p.hand):
|
||||
if card.is_land():
|
||||
@@ -285,23 +335,24 @@ class GameEngine:
|
||||
p.stats.mana_spent += card.cmc or 0
|
||||
p.stats.spells_cast += 1
|
||||
|
||||
# Handle removal — target the scariest creature across all opponents
|
||||
if card.is_removal:
|
||||
p.stats.removal_used += 1
|
||||
all_opp_creatures = []
|
||||
for oi in range(len(sim.players)):
|
||||
if oi == pi or sim.players[oi].eliminated:
|
||||
continue
|
||||
for c in sim.get_battlefield(oi):
|
||||
if c.is_creature():
|
||||
all_opp_creatures.append(c)
|
||||
if all_opp_creatures:
|
||||
all_opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = all_opp_creatures[0]
|
||||
sim.remove_from_battlefield(killed.id)
|
||||
sim.players[killed.owner_id].graveyard.append(killed)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
|
||||
# N-player: target highest-value opponent creature
|
||||
target_seat = self._pick_removal_target_seat(sim, pi)
|
||||
if target_seat != -1:
|
||||
opp_creatures = [
|
||||
c for c in sim.get_battlefield(target_seat) if c.is_creature()
|
||||
]
|
||||
if opp_creatures:
|
||||
opp_creatures.sort(key=lambda c: -score_card(c, w))
|
||||
killed = opp_creatures[0]
|
||||
sim.remove_from_battlefield(killed.id)
|
||||
sim.players[killed.owner_id].graveyard.append(killed)
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
|
||||
else:
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal, no targets)")
|
||||
else:
|
||||
if events is not None:
|
||||
events.append(f"Cast {card.name} (removal, no targets)")
|
||||
@@ -339,39 +390,19 @@ class GameEngine:
|
||||
events.append(f"Cast {card.name} for {card.cmc} mana")
|
||||
|
||||
played += 1
|
||||
|
||||
# Recalculate available mana for next spell
|
||||
available_mana = self._count_untapped_lands(sim, pi)
|
||||
|
||||
@staticmethod
|
||||
def _select_attack_target(sim: SimState, pi: int) -> int:
|
||||
"""Select the best opponent to attack.
|
||||
|
||||
Multiplayer heuristic: attack the weakest non-eliminated opponent
|
||||
(lowest life total). Ties broken by highest seat index (arbitrary
|
||||
but deterministic).
|
||||
|
||||
Returns the seat index of the chosen target, or -1 if no valid target.
|
||||
"""
|
||||
best_seat = -1
|
||||
best_life = float('inf')
|
||||
for si, sp in enumerate(sim.players):
|
||||
if si == pi or sp.eliminated:
|
||||
continue
|
||||
if sp.life < best_life:
|
||||
best_life = sp.life
|
||||
best_seat = si
|
||||
return best_seat
|
||||
|
||||
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
|
||||
"""Resolve combat phase for a player (N-player ready)."""
|
||||
"""Resolve combat phase: attacker targets the highest-threat alive opponent."""
|
||||
p = sim.players[pi]
|
||||
opp_idx = self._select_attack_target(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return # no valid targets
|
||||
opp = sim.players[opp_idx]
|
||||
w = self.weights
|
||||
|
||||
# N-player: pick target opponent by threat level
|
||||
opp_idx = self._pick_attack_target_seat(sim, pi)
|
||||
if opp_idx == -1:
|
||||
return
|
||||
opp = sim.players[opp_idx]
|
||||
|
||||
# My creatures that can attack (not tapped, not summoning sick)
|
||||
my_creatures = [
|
||||
c
|
||||
@@ -388,10 +419,7 @@ class GameEngine:
|
||||
opp_blockers = [
|
||||
c
|
||||
for c in sim.get_battlefield(opp_idx)
|
||||
if (
|
||||
c.is_creature()
|
||||
and not c.tapped
|
||||
)
|
||||
if c.is_creature() and not c.tapped
|
||||
]
|
||||
|
||||
# ── Decide attackers ──
|
||||
@@ -492,67 +520,64 @@ class GameEngine:
|
||||
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
|
||||
events.extend(combat_details)
|
||||
|
||||
if opp.life <= 0:
|
||||
opp.eliminated = True
|
||||
def _build_result(self, sim: SimState, final_turn: int) -> GameResult:
|
||||
"""Determine winner and assign finish positions for all N players."""
|
||||
num_players = len(sim.players)
|
||||
elimination_order = getattr(sim, "elimination_order", [])
|
||||
|
||||
def _build_result(
|
||||
self,
|
||||
sim: SimState,
|
||||
final_turn: int,
|
||||
elimination_order: list[int] | None = None,
|
||||
) -> GameResult:
|
||||
"""Determine winner and build result object (N-player).
|
||||
# Players still alive at end of game — determine winner by life total
|
||||
alive = [p for p in sim.players if not p.eliminated]
|
||||
|
||||
Finish positions:
|
||||
1 = winner (last player standing, or highest life if timeout)
|
||||
2..N = elimination order (last eliminated = 2nd place, etc.)
|
||||
"""
|
||||
if elimination_order is None:
|
||||
elimination_order = []
|
||||
n = len(sim.players)
|
||||
|
||||
# Determine winner: last player standing, or highest life among alive
|
||||
alive = [i for i, p in enumerate(sim.players) if not p.eliminated]
|
||||
if len(alive) == 1:
|
||||
winner = alive[0]
|
||||
elif len(alive) == 0:
|
||||
# Everyone dead — shouldn't happen but pick highest life
|
||||
winner = max(range(n), key=lambda i: sim.players[i].life)
|
||||
if not alive:
|
||||
# Edge case: everyone eliminated (e.g. simultaneous lethal)
|
||||
winner_seat = -1
|
||||
elif len(alive) == 1:
|
||||
winner_seat = alive[0].owner_id
|
||||
else:
|
||||
# Timeout — alive player with highest life wins
|
||||
winner = max(alive, key=lambda i: sim.players[i].life)
|
||||
# Multiple players survived max_turns — highest life wins
|
||||
winner_seat = max(
|
||||
(p.owner_id for p in alive),
|
||||
key=lambda idx: sim.players[idx].life,
|
||||
)
|
||||
|
||||
# Build finish positions
|
||||
# Winner = 1, then reverse elimination order (last eliminated = 2nd, etc.)
|
||||
finish: dict[int, int] = {winner: 1}
|
||||
position = 2
|
||||
for seat in reversed(elimination_order):
|
||||
if seat != winner:
|
||||
finish[seat] = position
|
||||
position += 1
|
||||
# Any alive non-winner seats get next positions (by life descending)
|
||||
remaining_alive = sorted(
|
||||
[i for i in alive if i != winner],
|
||||
key=lambda i: -sim.players[i].life,
|
||||
# Build finish positions:
|
||||
# 1st = winner, then survivors ordered by life (desc), then eliminated in reverse order
|
||||
# (last eliminated = best finish among eliminated)
|
||||
survivor_seats = sorted(
|
||||
[p.owner_id for p in alive if p.owner_id != winner_seat],
|
||||
key=lambda idx: -sim.players[idx].life,
|
||||
)
|
||||
for seat in remaining_alive:
|
||||
# elimination_order[0] = first out = worst finish
|
||||
# Finish positions: winner=1, survivors=2..k, eliminated reversed
|
||||
finish: dict[int, int] = {winner_seat: 1}
|
||||
pos = 2
|
||||
for seat in survivor_seats:
|
||||
finish[seat] = pos
|
||||
pos += 1
|
||||
for seat in reversed(elimination_order):
|
||||
if seat not in finish:
|
||||
finish[seat] = position
|
||||
position += 1
|
||||
finish[seat] = pos
|
||||
pos += 1
|
||||
# Any remaining (shouldn't happen, but safety)
|
||||
for seat in range(num_players):
|
||||
if seat not in finish:
|
||||
finish[seat] = pos
|
||||
pos += 1
|
||||
|
||||
player_results = []
|
||||
for seat, p in enumerate(sim.players):
|
||||
player_results.append(PlayerResult(
|
||||
player_results = [
|
||||
PlayerResult(
|
||||
seat_index=seat,
|
||||
name=p.name,
|
||||
life=p.life,
|
||||
eliminated=p.eliminated,
|
||||
finish_position=finish.get(seat, n),
|
||||
stats=p.stats,
|
||||
))
|
||||
name=sim.players[seat].name,
|
||||
life=sim.players[seat].life,
|
||||
eliminated=sim.players[seat].eliminated,
|
||||
finish_position=finish.get(seat, num_players),
|
||||
stats=sim.players[seat].stats,
|
||||
)
|
||||
for seat in range(num_players)
|
||||
]
|
||||
|
||||
return GameResult(
|
||||
winner_seat=winner,
|
||||
winner_seat=winner_seat,
|
||||
turns=min(final_turn, sim.max_turns),
|
||||
players=player_results,
|
||||
)
|
||||
@@ -4,23 +4,16 @@ Commander AI Lab — Simulator Rules & Helpers
|
||||
AI weights, card enrichment heuristics, and scoring functions.
|
||||
Ported from mtg-commander-lan JavaScript (AI_DEFAULT_WEIGHTS,
|
||||
enrichCardForSim, headlessScoreCard, headlessGetPower/Toughness/HasKw).
|
||||
|
||||
N-player ready: score_card() accepts num_opponents so keyword weights
|
||||
scale with pod size (e.g. Propaganda is worth more vs 3 opponents).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import random
|
||||
from typing import Optional
|
||||
|
||||
from commander_ai_lab.sim.models import Card
|
||||
|
||||
logger = logging.getLogger("rules")
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# AI Weights (ported from AI_DEFAULT_WEIGHTS in app.js)
|
||||
@@ -74,9 +67,6 @@ AI_DEFAULT_WEIGHTS: dict[str, float] = {
|
||||
"spell_cascade": 3.0,
|
||||
}
|
||||
|
||||
# Default path for persisted learned weights (sibling to this file)
|
||||
_DEFAULT_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "learned_weights.json")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Card Enrichment — guess type/cost for cards without Scryfall data
|
||||
@@ -140,12 +130,9 @@ def enrich_card(card: Card) -> Card:
|
||||
_apply_oracle_flags(card)
|
||||
return card
|
||||
|
||||
# Default: treat as creature with deterministic CMC based on
|
||||
# card name hash (fixes #32 — no more random.randint jitter
|
||||
# between games for the same card).
|
||||
# Default: treat as creature with random CMC
|
||||
if not card.type_line or len(card.type_line) < 3:
|
||||
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
|
||||
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
|
||||
estimated_cmc = random.randint(2, 6)
|
||||
estimated_pow = max(1, estimated_cmc - 1)
|
||||
card.type_line = "Creature"
|
||||
card.cmc = estimated_cmc
|
||||
@@ -194,46 +181,15 @@ def _apply_oracle_flags(card: Card) -> None:
|
||||
card.is_ramp = True
|
||||
|
||||
|
||||
# ── Multi-opponent scaling factors ────────────────────────────
|
||||
# Keywords / effects that become more valuable in larger pods.
|
||||
# Each entry maps a weight key → the extra bonus *per additional
|
||||
# opponent* beyond 1. 1v1 uses base weights; 4-player FFA adds
|
||||
# 3× the bonus for each matching keyword.
|
||||
_MULTI_OPP_SCALING: dict[str, float] = {
|
||||
# Group-slug / pillowfort effects punish each opponent
|
||||
"kw_menace": 0.5, # harder to block with more players
|
||||
"kw_flying": 0.3, # evasion matters more in FFA
|
||||
"kw_hexproof": 0.5, # more removal flying around
|
||||
"kw_indestructible": 0.5,
|
||||
"kw_ward": 0.4,
|
||||
# Multiplayer-relevant triggered abilities
|
||||
"trig_etb": 0.3,
|
||||
"trig_dies": 0.5, # more creatures dying in FFA
|
||||
# Spells that scale with opponents
|
||||
"spell_destroy": 0.5, # removal more precious
|
||||
"spell_draw": 0.3, # card advantage matters more
|
||||
"spell_counter": 0.5, # counters protect from more threats
|
||||
}
|
||||
|
||||
|
||||
def score_card(
|
||||
card: Card,
|
||||
weights: Optional[dict] = None,
|
||||
num_opponents: int = 1,
|
||||
) -> float:
|
||||
def score_card(card: Card, weights: Optional[dict] = None) -> float:
|
||||
"""
|
||||
Score a card for AI play priority.
|
||||
|
||||
Ported from headlessScoreCard() in app.js. The optional
|
||||
*num_opponents* parameter (default 1 for 1v1) scales keywords
|
||||
that become more valuable in larger pods.
|
||||
Ported from headlessScoreCard() in app.js.
|
||||
"""
|
||||
w = weights or AI_DEFAULT_WEIGHTS
|
||||
extra_opps = max(0, num_opponents - 1) # 0 for 1v1
|
||||
cmc = card.cmc or 0
|
||||
score = 10.0 - cmc * w.get("card_baseCmcPenalty", 1.0)
|
||||
|
||||
# ── Power / toughness bonus ──
|
||||
if card.pt:
|
||||
parts = card.pt.split("/")
|
||||
try:
|
||||
@@ -246,83 +202,22 @@ def score_card(
|
||||
t = 0
|
||||
score += (p + t) * w.get("card_ptBonus", 1.0)
|
||||
|
||||
# ── Keyword / oracle-text scoring ──
|
||||
text = (card.oracle_text or "").lower()
|
||||
kw_list = [k.lower() for k in (card.keywords or [])]
|
||||
if "flying" in text:
|
||||
score += w.get("kw_flying", 2.5)
|
||||
if "deathtouch" in text:
|
||||
score += w.get("kw_deathtouch", 2.5)
|
||||
if "haste" in text:
|
||||
score += w.get("kw_haste", 2.5)
|
||||
if "trample" in text:
|
||||
score += w.get("kw_trample", 1.5)
|
||||
if "lifelink" in text:
|
||||
score += w.get("kw_lifelink", 1.5)
|
||||
|
||||
def _kw_bonus(keyword: str, weight_key: str, default: float) -> float:
|
||||
"""Return base weight + per-extra-opponent scaling."""
|
||||
base = w.get(weight_key, default)
|
||||
multi = _MULTI_OPP_SCALING.get(weight_key, 0.0) * extra_opps
|
||||
return base + multi
|
||||
|
||||
if "flying" in text or "flying" in kw_list:
|
||||
score += _kw_bonus("flying", "kw_flying", 2.5)
|
||||
if "deathtouch" in text or "deathtouch" in kw_list:
|
||||
score += _kw_bonus("deathtouch", "kw_deathtouch", 2.5)
|
||||
if "haste" in text or "haste" in kw_list:
|
||||
score += _kw_bonus("haste", "kw_haste", 2.5)
|
||||
if "trample" in text or "trample" in kw_list:
|
||||
score += _kw_bonus("trample", "kw_trample", 1.5)
|
||||
if "lifelink" in text or "lifelink" in kw_list:
|
||||
score += _kw_bonus("lifelink", "kw_lifelink", 1.5)
|
||||
if "double strike" in text or "double strike" in kw_list:
|
||||
score += _kw_bonus("double strike", "kw_doubleStrike", 3.0)
|
||||
if "first strike" in text or "first strike" in kw_list:
|
||||
score += _kw_bonus("first strike", "kw_firstStrike", 1.5)
|
||||
if "hexproof" in text or "hexproof" in kw_list:
|
||||
score += _kw_bonus("hexproof", "kw_hexproof", 2.0)
|
||||
if "indestructible" in text or "indestructible" in kw_list:
|
||||
score += _kw_bonus("indestructible", "kw_indestructible", 3.0)
|
||||
if "menace" in text or "menace" in kw_list:
|
||||
score += _kw_bonus("menace", "kw_menace", 1.5)
|
||||
if "vigilance" in text or "vigilance" in kw_list:
|
||||
score += w.get("kw_vigilance", 1.0)
|
||||
if "ward" in text or "ward" in kw_list:
|
||||
score += _kw_bonus("ward", "kw_ward", 1.5)
|
||||
if "infect" in text or "infect" in kw_list:
|
||||
score += w.get("kw_infect", 3.0)
|
||||
if "annihilator" in text:
|
||||
score += w.get("kw_annihilator", 4.0)
|
||||
if "cascade" in text or "cascade" in kw_list:
|
||||
score += w.get("kw_cascade", 3.0)
|
||||
if "defender" in text or "defender" in kw_list:
|
||||
score += w.get("kw_defender", -2.0)
|
||||
|
||||
# ── Triggered ability bonuses ──
|
||||
if "when" in text and "enters" in text:
|
||||
score += w.get("trig_etb", 2.0) + _MULTI_OPP_SCALING.get("trig_etb", 0.0) * extra_opps
|
||||
if "whenever" in text and "attack" in text:
|
||||
score += w.get("trig_attack", 1.5)
|
||||
if "when" in text and "dies" in text:
|
||||
score += w.get("trig_dies", 1.0) + _MULTI_OPP_SCALING.get("trig_dies", 0.0) * extra_opps
|
||||
|
||||
# ── Multiplayer-specific oracle patterns ──
|
||||
if extra_opps > 0:
|
||||
# "each opponent" effects scale linearly with opponent count
|
||||
if "each opponent" in text:
|
||||
score += 2.0 * extra_opps
|
||||
# Pillowfort (Propaganda, Ghostly Prison, etc.)
|
||||
if "attack you" in text and ("pay" in text or "tax" in text or "can't" in text):
|
||||
score += 1.5 * extra_opps
|
||||
# "each player" draw/damage effects
|
||||
if "each player" in text:
|
||||
score += 1.0 * extra_opps
|
||||
|
||||
# ── Spell-type bonuses ──
|
||||
if card.is_ramp:
|
||||
score += w.get("spell_ramp", 6.0)
|
||||
if card.is_removal:
|
||||
score += _kw_bonus("removal", "spell_destroy", 5.0)
|
||||
if card.is_board_wipe:
|
||||
# Board wipes get better in multiplayer — more creatures to hit
|
||||
score += 1.0 * extra_opps
|
||||
if "draw" in text and ("card" in text or "cards" in text):
|
||||
score += w.get("spell_draw", 3.0) + _MULTI_OPP_SCALING.get("spell_draw", 0.0) * extra_opps
|
||||
if "counter target" in text:
|
||||
score += w.get("spell_counter", 4.0) + _MULTI_OPP_SCALING.get("spell_counter", 0.0) * extra_opps
|
||||
if "search your library" in text:
|
||||
score += w.get("spell_search", 2.0)
|
||||
score += w.get("spell_destroy", 5.0)
|
||||
|
||||
return score
|
||||
|
||||
@@ -347,73 +242,3 @@ def parse_decklist(text: str) -> list[Card]:
|
||||
for _ in range(qty):
|
||||
cards.append(Card(name=name))
|
||||
return cards
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# Learned Weights — persist & load simulation-derived weights
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
def load_weights(path: str = _DEFAULT_WEIGHTS_PATH) -> dict[str, float]:
|
||||
"""
|
||||
Load AI weights from a JSON file, falling back to AI_DEFAULT_WEIGHTS.
|
||||
|
||||
Merges loaded values on top of defaults so any new weight keys added
|
||||
to AI_DEFAULT_WEIGHTS are always present even in older saved files.
|
||||
|
||||
Usage::
|
||||
|
||||
from commander_ai_lab.sim.rules import load_weights
|
||||
weights = load_weights() # uses learned_weights.json if present
|
||||
weights = load_weights("path/to/custom.json") # custom path
|
||||
"""
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
loaded: dict = json.load(f)
|
||||
# Only accept numeric values; ignore stale / corrupted keys
|
||||
valid = {k: float(v) for k, v in loaded.items() if isinstance(v, (int, float))}
|
||||
merged = {**AI_DEFAULT_WEIGHTS, **valid}
|
||||
logger.info("Loaded %d learned weights from %s", len(valid), path)
|
||||
return merged
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load weights from %s: %s — using defaults", path, exc)
|
||||
return dict(AI_DEFAULT_WEIGHTS)
|
||||
|
||||
|
||||
def save_weights(weights: dict[str, float], path: str = _DEFAULT_WEIGHTS_PATH) -> str:
|
||||
"""
|
||||
Persist AI weights to a JSON file.
|
||||
|
||||
Clamps each value to [-20.0, 20.0] before saving to prevent runaway
|
||||
drift from repeated simulation updates.
|
||||
|
||||
Returns the absolute path that was written.
|
||||
|
||||
Usage::
|
||||
|
||||
from commander_ai_lab.sim.rules import save_weights
|
||||
save_weights(updated_weights) # default path
|
||||
save_weights(updated_weights, "path/to/custom.json")
|
||||
"""
|
||||
_CLAMP_MIN, _CLAMP_MAX = -20.0, 20.0
|
||||
clamped = {
|
||||
k: round(max(_CLAMP_MIN, min(_CLAMP_MAX, float(v))), 6)
|
||||
for k, v in weights.items()
|
||||
}
|
||||
os.makedirs(os.path.dirname(os.path.abspath(path)), exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(clamped, f, indent=2)
|
||||
logger.info("Saved %d weights to %s", len(clamped), path)
|
||||
return os.path.abspath(path)
|
||||
|
||||
|
||||
def reset_weights(path: str = _DEFAULT_WEIGHTS_PATH) -> dict[str, float]:
|
||||
"""
|
||||
Delete any persisted learned weights and return AI_DEFAULT_WEIGHTS.
|
||||
|
||||
Useful for starting a fresh learning run without stale drift.
|
||||
"""
|
||||
if os.path.exists(path):
|
||||
os.remove(path)
|
||||
logger.info("Deleted learned weights file: %s", path)
|
||||
return dict(AI_DEFAULT_WEIGHTS)
|
||||
Whitespace-only changes.
@@ -1,75 +0,0 @@
|
||||
"""Shared pytest fixtures for Commander AI Lab tests."""
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats, SimState
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_creature():
|
||||
return Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2", power="2", toughness="2")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def flying_creature():
|
||||
return Card(
|
||||
name="Serra Angel",
|
||||
type_line="Creature — Angel",
|
||||
cmc=5,
|
||||
pt="4/4",
|
||||
power="4",
|
||||
toughness="4",
|
||||
oracle_text="Flying, vigilance",
|
||||
keywords=["Flying", "Vigilance"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def basic_land():
|
||||
return Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sol_ring():
|
||||
return Card(name="Sol Ring", type_line="Artifact", cmc=1, oracle_text="{T}: Add {C}{C}", is_ramp=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def removal_spell():
|
||||
return Card(name="Murder", type_line="Instant", cmc=3, oracle_text="Destroy target creature.", is_removal=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def board_wipe():
|
||||
return Card(name="Wrath of God", type_line="Sorcery", cmc=4, oracle_text="Destroy all creatures.", is_board_wipe=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def commander_card():
|
||||
return Card(
|
||||
name="Korvold, Fae-Cursed King",
|
||||
type_line="Legendary Creature — Dragon Noble",
|
||||
cmc=5,
|
||||
pt="4/4",
|
||||
power="4",
|
||||
toughness="4",
|
||||
is_commander=True,
|
||||
oracle_text="Flying, haste\nWhenever Korvold, Fae-Cursed King enters the battlefield or attacks, sacrifice another permanent.",
|
||||
keywords=["Flying", "Haste"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_player(basic_creature, basic_land, sol_ring):
|
||||
p = Player(name="Alice", life=40, owner_id=0)
|
||||
p.library = [basic_creature.clone(), basic_land.clone(), sol_ring.clone()] * 20
|
||||
return p
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def two_player_state(sample_player):
|
||||
import copy
|
||||
p2 = copy.deepcopy(sample_player)
|
||||
p2.name = "Bob"
|
||||
p2.owner_id = 1
|
||||
state = SimState(players=[sample_player, p2])
|
||||
state.init_battlefields(2)
|
||||
return state
|
||||
Whitespace-only changes.
@@ -1,97 +0,0 @@
|
||||
"""Seeded simulation tests for the game engine."""
|
||||
import random
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card, Player, PlayerStats
|
||||
from commander_ai_lab.sim.rules import enrich_card
|
||||
from commander_ai_lab.sim.engine import GameEngine
|
||||
|
||||
|
||||
def _make_deck(seed: int, size: int = 60) -> list[Card]:
|
||||
"""Build a deterministic test deck using a seeded RNG."""
|
||||
rng = random.Random(seed)
|
||||
card_pool = [
|
||||
Card(name="Forest"),
|
||||
Card(name="Sol Ring"),
|
||||
Card(name="Cultivate"),
|
||||
Card(name="Murder"),
|
||||
Card(name="Wrath of God"),
|
||||
Card(name="Grizzly Bears"),
|
||||
Card(name="Serra Angel"),
|
||||
]
|
||||
for c in card_pool:
|
||||
enrich_card(c)
|
||||
deck = []
|
||||
for i in range(size):
|
||||
deck.append(card_pool[i % len(card_pool)].clone())
|
||||
rng.shuffle(deck)
|
||||
return deck
|
||||
|
||||
|
||||
class TestGameEngineTwoPlayer:
|
||||
def test_game_completes(self):
|
||||
"""A 2-player seeded game should finish without error."""
|
||||
engine = GameEngine(seed=42)
|
||||
deck_a = _make_deck(seed=1)
|
||||
deck_b = _make_deck(seed=2)
|
||||
result = engine.run_game(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
)
|
||||
assert result is not None
|
||||
assert result.turns > 0
|
||||
|
||||
def test_result_has_winner_or_draw(self):
|
||||
"""winner_seat must be 0, 1, or -1 (draw)."""
|
||||
engine = GameEngine(seed=99)
|
||||
deck = _make_deck(seed=5)
|
||||
result = engine.run_game(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
player_a_name="P1",
|
||||
player_b_name="P2",
|
||||
)
|
||||
assert result.winner_seat in (-1, 0, 1)
|
||||
|
||||
def test_seeded_game_is_deterministic(self):
|
||||
"""Same seed must produce identical results."""
|
||||
deck_a = _make_deck(seed=7)
|
||||
deck_b = _make_deck(seed=8)
|
||||
|
||||
engine1 = GameEngine(seed=42)
|
||||
result1 = engine1.run_game(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
)
|
||||
|
||||
engine2 = GameEngine(seed=42)
|
||||
result2 = engine2.run_game(
|
||||
deck_a=[c.clone() for c in deck_a],
|
||||
deck_b=[c.clone() for c in deck_b],
|
||||
player_a_name="Alice",
|
||||
player_b_name="Bob",
|
||||
)
|
||||
|
||||
assert result1.winner_seat == result2.winner_seat
|
||||
assert result1.turns == result2.turns
|
||||
|
||||
def test_to_dict_serializable(self):
|
||||
"""Game result must serialize to a dict with expected keys."""
|
||||
import json
|
||||
engine = GameEngine(seed=77)
|
||||
deck = _make_deck(seed=3)
|
||||
result = engine.run_game(
|
||||
deck_a=[c.clone() for c in deck],
|
||||
deck_b=[c.clone() for c in deck],
|
||||
player_a_name="A",
|
||||
player_b_name="B",
|
||||
)
|
||||
d = result.to_dict()
|
||||
assert "winner" in d
|
||||
assert "turns" in d
|
||||
assert "playerResults" in d
|
||||
# Ensure it's JSON-serializable
|
||||
json.dumps(d)
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Tests for commander_ai_lab.sim.models."""
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import (
|
||||
Card, Player, PlayerStats, PlayerResult, GameResult, SimState
|
||||
)
|
||||
|
||||
|
||||
# ── Card ────────────────────────────────────────────────────
|
||||
|
||||
class TestCardGetPower:
|
||||
def test_from_power_field(self):
|
||||
c = Card(name="Test", power="3", toughness="2", pt="3/2")
|
||||
assert c.get_power() == 3
|
||||
|
||||
def test_from_pt_field(self):
|
||||
c = Card(name="Test", pt="4/5")
|
||||
assert c.get_power() == 4
|
||||
|
||||
def test_no_pt_returns_zero(self):
|
||||
c = Card(name="Test")
|
||||
assert c.get_power() == 0
|
||||
|
||||
def test_non_numeric_returns_zero(self):
|
||||
c = Card(name="Test", power="*", pt="*/2")
|
||||
assert c.get_power() == 0
|
||||
|
||||
|
||||
class TestCardGetToughness:
|
||||
def test_from_toughness_field(self):
|
||||
c = Card(name="Test", power="2", toughness="5", pt="2/5")
|
||||
assert c.get_toughness() == 5
|
||||
|
||||
def test_from_pt_field(self):
|
||||
c = Card(name="Test", pt="2/7")
|
||||
assert c.get_toughness() == 7
|
||||
|
||||
def test_no_pt_returns_zero(self):
|
||||
c = Card(name="Test")
|
||||
assert c.get_toughness() == 0
|
||||
|
||||
|
||||
class TestCardHasKeyword:
|
||||
def test_in_keywords_list(self):
|
||||
c = Card(name="Test", keywords=["Flying", "Haste"])
|
||||
assert c.has_keyword("flying") is True
|
||||
assert c.has_keyword("haste") is True
|
||||
|
||||
def test_in_oracle_text(self):
|
||||
c = Card(name="Test", oracle_text="This creature has flying and deathtouch.")
|
||||
assert c.has_keyword("flying") is True
|
||||
assert c.has_keyword("deathtouch") is True
|
||||
|
||||
def test_missing_keyword(self):
|
||||
c = Card(name="Test", oracle_text="Draw a card.", keywords=[])
|
||||
assert c.has_keyword("flying") is False
|
||||
|
||||
def test_case_insensitive(self):
|
||||
c = Card(name="Test", keywords=["FLYING"])
|
||||
assert c.has_keyword("Flying") is True
|
||||
|
||||
|
||||
class TestCardIsLand:
|
||||
def test_basic_land(self):
|
||||
c = Card(name="Forest", type_line="Basic Land — Forest")
|
||||
assert c.is_land() is True
|
||||
|
||||
def test_nonbasic_land(self):
|
||||
c = Card(name="Command Tower", type_line="Land")
|
||||
assert c.is_land() is True
|
||||
|
||||
def test_creature_not_land(self):
|
||||
c = Card(name="Grizzly Bears", type_line="Creature — Bear")
|
||||
assert c.is_land() is False
|
||||
|
||||
def test_no_type_line(self):
|
||||
c = Card(name="Unknown")
|
||||
assert c.is_land() is False
|
||||
|
||||
|
||||
class TestCardIsCreature:
|
||||
def test_creature(self):
|
||||
c = Card(name="Grizzly Bears", type_line="Creature — Bear")
|
||||
assert c.is_creature() is True
|
||||
|
||||
def test_legendary_creature(self):
|
||||
c = Card(name="Korvold", type_line="Legendary Creature — Dragon Noble")
|
||||
assert c.is_creature() is True
|
||||
|
||||
def test_instant_not_creature(self):
|
||||
c = Card(name="Murder", type_line="Instant")
|
||||
assert c.is_creature() is False
|
||||
|
||||
def test_land_not_creature(self):
|
||||
c = Card(name="Forest", type_line="Basic Land — Forest")
|
||||
assert c.is_creature() is False
|
||||
|
||||
|
||||
class TestCardClone:
|
||||
def test_clone_is_equal(self, basic_creature):
|
||||
clone = basic_creature.clone()
|
||||
assert clone.name == basic_creature.name
|
||||
assert clone.cmc == basic_creature.cmc
|
||||
|
||||
def test_clone_is_independent(self, basic_creature):
|
||||
clone = basic_creature.clone()
|
||||
clone.name = "Changed"
|
||||
assert basic_creature.name == "Grizzly Bears"
|
||||
|
||||
|
||||
# ── PlayerStats ─────────────────────────────────────────────
|
||||
|
||||
class TestPlayerStats:
|
||||
def test_to_dict_keys(self):
|
||||
stats = PlayerStats(cards_drawn=7, damage_dealt=10)
|
||||
d = stats.to_dict()
|
||||
assert "cardsDrawn" in d
|
||||
assert "damageDealt" in d
|
||||
assert d["cardsDrawn"] == 7
|
||||
assert d["damageDealt"] == 10
|
||||
|
||||
def test_default_values(self):
|
||||
stats = PlayerStats()
|
||||
assert stats.cards_drawn == 7
|
||||
assert stats.lands_played == 0
|
||||
|
||||
|
||||
# ── PlayerResult & GameResult ────────────────────────────────
|
||||
|
||||
class TestPlayerResult:
|
||||
def test_to_dict_winner(self):
|
||||
pr = PlayerResult(seat_index=0, name="Alice", life=40, finish_position=1)
|
||||
d = pr.to_dict()
|
||||
assert d["isWinner"] is True
|
||||
assert d["seatIndex"] == 0
|
||||
assert d["name"] == "Alice"
|
||||
|
||||
def test_to_dict_loser(self):
|
||||
pr = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2)
|
||||
d = pr.to_dict()
|
||||
assert d["isWinner"] is False
|
||||
assert d["eliminated"] is True
|
||||
|
||||
|
||||
class TestGameResult:
|
||||
def _make_result(self):
|
||||
p0 = PlayerResult(seat_index=0, name="Alice", life=35, finish_position=1, stats=PlayerStats(damage_dealt=20))
|
||||
p1 = PlayerResult(seat_index=1, name="Bob", life=0, eliminated=True, finish_position=2, stats=PlayerStats())
|
||||
return GameResult(winner_seat=0, turns=12, players=[p0, p1])
|
||||
|
||||
def test_winner_property(self):
|
||||
gr = self._make_result()
|
||||
assert gr.winner == 0
|
||||
|
||||
def test_player_a_name(self):
|
||||
gr = self._make_result()
|
||||
assert gr.player_a_name == "Alice"
|
||||
|
||||
def test_player_b_name(self):
|
||||
gr = self._make_result()
|
||||
assert gr.player_b_name == "Bob"
|
||||
|
||||
def test_player_a_eliminated(self):
|
||||
gr = self._make_result()
|
||||
assert gr.player_a_eliminated is False
|
||||
|
||||
def test_player_b_eliminated(self):
|
||||
gr = self._make_result()
|
||||
assert gr.player_b_eliminated is True
|
||||
|
||||
def test_to_dict_has_legacy_keys(self):
|
||||
gr = self._make_result()
|
||||
d = gr.to_dict()
|
||||
assert "winner" in d
|
||||
assert "turns" in d
|
||||
assert "playerResults" in d
|
||||
assert len(d["playerResults"]) == 2
|
||||
|
||||
|
||||
# ── SimState ─────────────────────────────────────────────────
|
||||
|
||||
class TestSimState:
|
||||
def test_init_battlefields(self):
|
||||
state = SimState()
|
||||
state.init_battlefields(4)
|
||||
assert len(state.battlefields) == 4
|
||||
|
||||
def test_add_and_get_battlefield(self):
|
||||
state = SimState()
|
||||
c = Card(name="Test", id=1)
|
||||
state.add_to_battlefield(0, c)
|
||||
bf = state.get_battlefield(0)
|
||||
assert len(bf) == 1
|
||||
assert bf[0].name == "Test"
|
||||
|
||||
def test_remove_from_battlefield(self):
|
||||
state = SimState()
|
||||
c = Card(name="Test", id=42)
|
||||
state.add_to_battlefield(0, c)
|
||||
removed = state.remove_from_battlefield(42)
|
||||
assert removed is not None
|
||||
assert removed.name == "Test"
|
||||
assert len(state.get_battlefield(0)) == 0
|
||||
|
||||
def test_remove_nonexistent_returns_none(self):
|
||||
state = SimState()
|
||||
assert state.remove_from_battlefield(999) is None
|
||||
|
||||
def test_all_battlefield_cards(self):
|
||||
state = SimState()
|
||||
state.add_to_battlefield(0, Card(name="A", id=1))
|
||||
state.add_to_battlefield(1, Card(name="B", id=2))
|
||||
state.add_to_battlefield(1, Card(name="C", id=3))
|
||||
all_cards = state.all_battlefield_cards()
|
||||
assert len(all_cards) == 3
|
||||
@@ -1,189 +0,0 @@
|
||||
"""Tests for commander_ai_lab.sim.rules."""
|
||||
import pytest
|
||||
from commander_ai_lab.sim.models import Card
|
||||
from commander_ai_lab.sim.rules import enrich_card, score_card, parse_decklist, AI_DEFAULT_WEIGHTS
|
||||
|
||||
|
||||
# ── enrich_card ──────────────────────────────────────────────
|
||||
|
||||
class TestEnrichCardBasicLands:
|
||||
@pytest.mark.parametrize("name,expected_color", [
|
||||
("Forest", "G"),
|
||||
("Island", "U"),
|
||||
("Plains", "W"),
|
||||
("Swamp", "B"),
|
||||
("Mountain", "R"),
|
||||
])
|
||||
def test_basic_land_color(self, name, expected_color):
|
||||
c = Card(name=name)
|
||||
enrich_card(c)
|
||||
assert c.is_land() is True
|
||||
assert expected_color in c.oracle_text
|
||||
assert c.cmc == 0
|
||||
|
||||
|
||||
class TestEnrichCardKnownCards:
|
||||
def test_sol_ring(self):
|
||||
c = Card(name="Sol Ring")
|
||||
enrich_card(c)
|
||||
assert c.is_ramp is True
|
||||
assert c.cmc == 1
|
||||
|
||||
def test_murder(self):
|
||||
c = Card(name="Murder")
|
||||
enrich_card(c)
|
||||
assert c.is_removal is True
|
||||
assert c.cmc == 3
|
||||
|
||||
def test_wrath_of_god(self):
|
||||
c = Card(name="Wrath of God")
|
||||
enrich_card(c)
|
||||
assert c.is_board_wipe is True
|
||||
|
||||
def test_cultivate(self):
|
||||
c = Card(name="Cultivate")
|
||||
enrich_card(c)
|
||||
assert c.is_ramp is True
|
||||
assert c.cmc == 3
|
||||
|
||||
def test_counterspell(self):
|
||||
c = Card(name="Counterspell")
|
||||
enrich_card(c)
|
||||
assert c.is_removal is True
|
||||
|
||||
|
||||
class TestEnrichCardDeterministic:
|
||||
def test_same_unknown_card_same_cmc(self):
|
||||
"""Unknown cards must get deterministic CMC (no random jitter)."""
|
||||
c1 = Card(name="Unknown Foobar Card")
|
||||
c2 = Card(name="Unknown Foobar Card")
|
||||
enrich_card(c1)
|
||||
enrich_card(c2)
|
||||
assert c1.cmc == c2.cmc
|
||||
assert c1.pt == c2.pt
|
||||
|
||||
def test_different_unknown_cards_may_differ(self):
|
||||
c1 = Card(name="Aaaaa")
|
||||
c2 = Card(name="Zzzzz")
|
||||
enrich_card(c1)
|
||||
enrich_card(c2)
|
||||
# At minimum they are both valid creatures with cmc in [2,6]
|
||||
assert 2 <= c1.cmc <= 6
|
||||
assert 2 <= c2.cmc <= 6
|
||||
|
||||
def test_unknown_card_cmc_range(self):
|
||||
for name in ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"]:
|
||||
c = Card(name=name)
|
||||
enrich_card(c)
|
||||
assert 2 <= c.cmc <= 6
|
||||
|
||||
|
||||
class TestEnrichCardPreservesExistingData:
|
||||
def test_real_data_not_overwritten(self):
|
||||
c = Card(name="Custom Card", type_line="Legendary Creature", cmc=7, pt="6/6")
|
||||
enrich_card(c)
|
||||
assert c.cmc == 7
|
||||
assert c.pt == "6/6"
|
||||
|
||||
|
||||
class TestEnrichCardOracleFlags:
|
||||
def test_removal_flag_from_oracle(self):
|
||||
c = Card(name="Custom Removal", type_line="Instant", cmc=2,
|
||||
oracle_text="Destroy target creature.")
|
||||
enrich_card(c)
|
||||
assert c.is_removal is True
|
||||
|
||||
def test_board_wipe_flag_from_oracle(self):
|
||||
c = Card(name="Custom Wipe", type_line="Sorcery", cmc=5,
|
||||
oracle_text="Destroy all creatures.")
|
||||
enrich_card(c)
|
||||
assert c.is_board_wipe is True
|
||||
|
||||
def test_ramp_flag_from_oracle(self):
|
||||
c = Card(name="Custom Ramp", type_line="Sorcery", cmc=2,
|
||||
oracle_text="Search your library for a basic land and put it onto the battlefield.")
|
||||
enrich_card(c)
|
||||
assert c.is_ramp is True
|
||||
|
||||
|
||||
# ── score_card ───────────────────────────────────────────────
|
||||
|
||||
class TestScoreCard:
|
||||
def test_land_scores_lower_than_creature(self):
|
||||
land = Card(name="Forest", type_line="Basic Land — Forest", cmc=0)
|
||||
creature = Card(name="Grizzly Bears", type_line="Creature — Bear", cmc=2, pt="2/2")
|
||||
enrich_card(land)
|
||||
enrich_card(creature)
|
||||
# Creature with pt bonus should score higher than a plain land
|
||||
assert score_card(creature) > score_card(land)
|
||||
|
||||
def test_flying_bonus(self):
|
||||
base = Card(name="Vanilla", type_line="Creature", cmc=3, pt="3/3")
|
||||
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
|
||||
oracle_text="Flying", keywords=["Flying"])
|
||||
assert score_card(flyer) > score_card(base)
|
||||
|
||||
def test_ramp_bonus(self):
|
||||
ramp = Card(name="Sol Ring", type_line="Artifact", cmc=1, is_ramp=True)
|
||||
plain = Card(name="Mox Nothing", type_line="Artifact", cmc=1)
|
||||
assert score_card(ramp) > score_card(plain)
|
||||
|
||||
def test_removal_bonus(self):
|
||||
removal = Card(name="Murder", type_line="Instant", cmc=3, is_removal=True,
|
||||
oracle_text="Destroy target creature.")
|
||||
plain = Card(name="Shock", type_line="Instant", cmc=1)
|
||||
assert score_card(removal) > score_card(plain)
|
||||
|
||||
def test_multiplayer_scales_flying(self):
|
||||
flyer = Card(name="Flyer", type_line="Creature", cmc=3, pt="3/3",
|
||||
oracle_text="Flying", keywords=["Flying"])
|
||||
score_1v1 = score_card(flyer, num_opponents=1)
|
||||
score_4p = score_card(flyer, num_opponents=3)
|
||||
assert score_4p > score_1v1
|
||||
|
||||
def test_defender_penalty(self):
|
||||
defender = Card(name="Wall", type_line="Creature", cmc=2, pt="0/5",
|
||||
oracle_text="Defender", keywords=["Defender"])
|
||||
vanilla = Card(name="Vanilla", type_line="Creature", cmc=2, pt="2/2")
|
||||
assert score_card(defender) < score_card(vanilla)
|
||||
|
||||
def test_high_cmc_penalized(self):
|
||||
cheap = Card(name="Cheap", type_line="Creature", cmc=1, pt="1/1")
|
||||
expensive = Card(name="Expensive", type_line="Creature", cmc=8, pt="1/1")
|
||||
assert score_card(cheap) > score_card(expensive)
|
||||
|
||||
|
||||
# ── parse_decklist ───────────────────────────────────────────
|
||||
|
||||
class TestParseDecklist:
|
||||
def test_numbered_entries(self):
|
||||
text = "4 Lightning Bolt\n2 Counterspell\n1 Black Lotus"
|
||||
cards = parse_decklist(text)
|
||||
assert len(cards) == 7
|
||||
assert cards[0].name == "Lightning Bolt"
|
||||
|
||||
def test_unnumbered_entries(self):
|
||||
cards = parse_decklist("Lightning Bolt")
|
||||
assert len(cards) == 1
|
||||
assert cards[0].name == "Lightning Bolt"
|
||||
|
||||
def test_skips_comments(self):
|
||||
text = "// This is a comment\n1 Sol Ring\n# Also a comment\n1 Forest"
|
||||
cards = parse_decklist(text)
|
||||
assert len(cards) == 2
|
||||
|
||||
def test_skips_blank_lines(self):
|
||||
cards = parse_decklist("1 Sol Ring\n\n\n1 Forest")
|
||||
assert len(cards) == 2
|
||||
|
||||
def test_returns_card_objects(self):
|
||||
cards = parse_decklist("1 Sol Ring")
|
||||
from commander_ai_lab.sim.models import Card
|
||||
assert isinstance(cards[0], Card)
|
||||
|
||||
def test_empty_input(self):
|
||||
assert parse_decklist("") == []
|
||||
|
||||
def test_multi_word_card_names(self):
|
||||
cards = parse_decklist("1 Kodama's Reach")
|
||||
assert cards[0].name == "Kodama's Reach"
|
||||
+296
-45
@@ -617,7 +617,7 @@
|
||||
|
||||
.lab-deck-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-5);
|
||||
}
|
||||
@@ -887,7 +887,7 @@
|
||||
|
||||
.lab-stats-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-3);
|
||||
margin-bottom: var(--sp-4);
|
||||
}
|
||||
@@ -972,7 +972,6 @@
|
||||
.seat-0-color { background: var(--accent); }
|
||||
.seat-1-color { background: var(--error); }
|
||||
.seat-2-color { background: var(--success); }
|
||||
.seat-3-color { background: #d29922; }
|
||||
|
||||
/* ── Win Condition Breakdown ───────────────────────────── */
|
||||
|
||||
@@ -1173,7 +1172,7 @@
|
||||
|
||||
.lab-combat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
||||
@@ -1457,16 +1456,6 @@
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────── */
|
||||
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.lab-deck-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.lab-combat-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.lab-deck-grid,
|
||||
.lab-stats-cards {
|
||||
@@ -1499,59 +1488,321 @@
|
||||
width: 95%;
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
.lab-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.lab-combat-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.lab-h2h-table th,
|
||||
.lab-h2h-table td {
|
||||
padding: var(--sp-2);
|
||||
font-size: 11px;
|
||||
.lab-charts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Seat Color Overrides ──────────────────────────────── */
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
Precon Decks — Grid + Cards
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
.lab-deck-slot[data-seat="0"] {
|
||||
--seat-accent: var(--accent);
|
||||
.lab-precon-btn {
|
||||
background: linear-gradient(135deg, var(--surface-0), var(--surface-2)) !important;
|
||||
border-color: var(--border) !important;
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="0"].filled {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
.lab-precon-btn:hover {
|
||||
background: linear-gradient(135deg, var(--surface-2), var(--surface-3)) !important;
|
||||
border-color: var(--error) !important;
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="1"] {
|
||||
--seat-accent: var(--error);
|
||||
.lab-precon-controls {
|
||||
display: flex;
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5) var(--sp-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="1"].filled {
|
||||
.lab-precon-controls input {
|
||||
flex: 1;
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lab-precon-controls input:focus {
|
||||
outline: none;
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 8px var(--error-glow, rgba(248, 81, 73, 0.25));
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="2"] {
|
||||
--seat-accent: var(--success);
|
||||
.lab-precon-install-all-btn {
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--sp-2) var(--sp-4);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="2"].filled {
|
||||
.lab-precon-install-all-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.lab-precon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: var(--sp-3);
|
||||
padding: 0 var(--sp-5) var(--sp-5);
|
||||
max-height: 55vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.lab-precon-card {
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-4);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s var(--ease);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lab-precon-card:hover {
|
||||
border-color: var(--error);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 16px var(--error-dim);
|
||||
}
|
||||
|
||||
.lab-precon-card-installed {
|
||||
border-color: var(--success);
|
||||
box-shadow: 0 0 8px var(--success-glow, rgba(63, 185, 80, 0.25));
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="3"] {
|
||||
--seat-accent: #d29922;
|
||||
.lab-precon-card-installed .lab-precon-install-btn {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.lab-deck-slot[data-seat="3"].filled {
|
||||
border-color: #d29922;
|
||||
box-shadow: 0 0 8px rgba(210, 153, 34, 0.25);
|
||||
.precon-card-colors {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
/* Seat label coloring */
|
||||
.lab-deck-slot[data-seat="0"] .seat-label { color: var(--accent); }
|
||||
.lab-deck-slot[data-seat="1"] .seat-label { color: var(--error); }
|
||||
.lab-deck-slot[data-seat="2"] .seat-label { color: var(--success); }
|
||||
.lab-deck-slot[data-seat="3"] .seat-label { color: #d29922; }
|
||||
.precon-card-name {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.precon-card-commander {
|
||||
font-size: 12px;
|
||||
color: var(--error);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.precon-card-set {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.precon-card-theme {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
line-height: 1.3;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.lab-precon-install-btn {
|
||||
margin-top: var(--sp-2);
|
||||
width: 100%;
|
||||
background: var(--surface-3);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
padding: 6px var(--sp-3);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all var(--duration) var(--ease);
|
||||
}
|
||||
|
||||
.lab-precon-install-btn:hover {
|
||||
background: var(--surface-hover);
|
||||
border-color: var(--error);
|
||||
}
|
||||
|
||||
.badge-precon {
|
||||
background: var(--surface-3);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
/* ══════════════════════════════════════════════════════════════
|
||||
v5: Analytics & Trends Panels
|
||||
══════════════════════════════════════════════════════════════ */
|
||||
|
||||
.lab-empty-hint {
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
padding: var(--sp-4) 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lab-loading-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--sp-3);
|
||||
color: var(--text-secondary);
|
||||
font-size: 13px;
|
||||
padding: var(--sp-6) 0;
|
||||
}
|
||||
|
||||
/* Analytics Grid */
|
||||
.lab-analytics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.lab-analytics-card {
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.lab-analytics-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.lab-analytics-error {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.lab-analytics-name {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 15px;
|
||||
margin-bottom: var(--sp-1);
|
||||
}
|
||||
|
||||
.lab-analytics-commander {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.lab-analytics-err-msg {
|
||||
color: var(--error);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.lab-analytics-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--sp-1) 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.lab-analytics-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.lab-analytics-value {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.lab-analytics-sections {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-1);
|
||||
margin-top: var(--sp-3);
|
||||
}
|
||||
|
||||
.lab-analytics-section-pill {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: 2px var(--sp-2);
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Trends */
|
||||
.lab-trends-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: var(--sp-4);
|
||||
padding: var(--sp-2) 0;
|
||||
}
|
||||
|
||||
.lab-trends-card {
|
||||
background: var(--surface-0);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--sp-4);
|
||||
}
|
||||
|
||||
.lab-trends-card:hover {
|
||||
border-color: var(--border-focus);
|
||||
}
|
||||
|
||||
.lab-trends-deck-name {
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
margin-bottom: var(--sp-3);
|
||||
}
|
||||
|
||||
.lab-trends-stats {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.lab-trends-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.lab-trends-stat-label {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.lab-trends-stat-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* Precon Refresh Button */
|
||||
.lab-precon-refresh-btn {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
transition: all 180ms ease;
|
||||
}
|
||||
|
||||
.lab-precon-refresh-btn:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
+2531
-1
File diff suppressed because it is too large.
Load diff
-137
@@ -727,140 +727,3 @@ table { border-collapse: collapse; width: 100%; }
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════
|
||||
PREMIUM UPGRADE LAYER
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
|
||||
/* ── Ambient background glow (body) ─────────────────────── */
|
||||
body {
|
||||
background-image:
|
||||
radial-gradient(ellipse 80% 50% at 10% -10%, rgba(91,158,240,0.07) 0%, transparent 60%),
|
||||
radial-gradient(ellipse 60% 40% at 90% 110%, rgba(167,139,250,0.05) 0%, transparent 60%);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* ── Page title: subtle gradient text ───────────────────── */
|
||||
.page-header h1 {
|
||||
background: linear-gradient(135deg, #fff 60%, rgba(91,158,240,0.85) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* ── Nav: stronger glass + accent underline ─────────────── */
|
||||
.nav {
|
||||
background: rgba(9,11,16,0.80);
|
||||
backdrop-filter: blur(20px) saturate(1.8);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(1.8);
|
||||
box-shadow: 0 1px 0 rgba(91,158,240,0.08), 0 4px 24px rgba(0,0,0,0.3);
|
||||
border-bottom-color: rgba(255,255,255,0.06);
|
||||
}
|
||||
.nav-brand svg { filter: drop-shadow(0 0 8px rgba(91,158,240,0.4)); }
|
||||
.nav-links a.active { box-shadow: inset 0 0 0 1px rgba(91,158,240,0.2); }
|
||||
|
||||
/* ── Panels: gradient border on hover ───────────────────── */
|
||||
.panel {
|
||||
position: relative;
|
||||
transition: border-color var(--duration) var(--ease),
|
||||
box-shadow var(--duration) var(--ease);
|
||||
}
|
||||
.panel::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1px;
|
||||
background: linear-gradient(135deg, rgba(91,158,240,0.18), transparent 50%, rgba(167,139,250,0.1));
|
||||
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--duration) var(--ease);
|
||||
}
|
||||
.panel:hover::before { opacity: 1; }
|
||||
.panel:hover {
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(91,158,240,0.08);
|
||||
}
|
||||
|
||||
/* ── Stat cards: gradient surface + shimmer top edge ─────── */
|
||||
.stat-card {
|
||||
position: relative;
|
||||
background: linear-gradient(145deg, var(--surface-2), var(--surface-1));
|
||||
overflow: hidden;
|
||||
}
|
||||
.stat-card::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.12), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
.stat-card:hover {
|
||||
box-shadow: var(--shadow-md), 0 0 20px rgba(91,158,240,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Buttons: gradient fills + inner highlight ───────────── */
|
||||
.btn { position: relative; overflow: hidden; }
|
||||
.btn::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: linear-gradient(to bottom, rgba(255,255,255,0.07), transparent);
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--accent), #4a8de0);
|
||||
box-shadow: 0 2px 8px rgba(91,158,240,0.25);
|
||||
}
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--accent-hover), var(--accent));
|
||||
box-shadow: 0 4px 20px rgba(91,158,240,0.4);
|
||||
}
|
||||
.btn-success {
|
||||
background: linear-gradient(135deg, var(--success), #38c46c);
|
||||
box-shadow: 0 2px 8px rgba(74,222,128,0.2);
|
||||
}
|
||||
.btn-success:hover { box-shadow: 0 4px 20px rgba(74,222,128,0.4); transform: translateY(-1px); }
|
||||
.btn-purple {
|
||||
background: linear-gradient(135deg, var(--purple), #9171f0);
|
||||
box-shadow: 0 2px 8px rgba(167,139,250,0.2);
|
||||
}
|
||||
.btn-purple:hover { box-shadow: 0 4px 20px rgba(167,139,250,0.4); transform: translateY(-1px); }
|
||||
|
||||
/* ── Tags: subtle colored border ────────────────────────── */
|
||||
.tag-blue { border: 1px solid rgba(91,158,240,0.25); }
|
||||
.tag-green { border: 1px solid rgba(74,222,128,0.25); }
|
||||
.tag-yellow { border: 1px solid rgba(251,191,36,0.25); }
|
||||
.tag-red { border: 1px solid rgba(248,113,113,0.25); }
|
||||
.tag-purple { border: 1px solid rgba(167,139,250,0.25); }
|
||||
|
||||
/* ── Progress fill: glow ─────────────────────────────────── */
|
||||
.progress-fill { box-shadow: 0 0 8px rgba(91,158,240,0.5); }
|
||||
|
||||
/* ── Badge running: glow dot ─────────────────────────────── */
|
||||
.badge-running { box-shadow: 0 0 12px rgba(91,158,240,0.15); }
|
||||
.badge-running::before { box-shadow: 0 0 6px var(--accent); }
|
||||
|
||||
/* ── Toast: glassmorphism + colored glow ─────────────────── */
|
||||
.toast { backdrop-filter: blur(12px); }
|
||||
.toast-success { box-shadow: var(--shadow-lg), 0 0 20px rgba(74,222,128,0.1); }
|
||||
.toast-error { box-shadow: var(--shadow-lg), 0 0 20px rgba(248,113,113,0.1); }
|
||||
|
||||
/* ── Divider: fade-out gradient ──────────────────────────── */
|
||||
.divider {
|
||||
background: linear-gradient(90deg, transparent, var(--border), transparent);
|
||||
}
|
||||
|
||||
/* ── Inputs: inset shadow for depth ─────────────────────── */
|
||||
.form-input, .form-select, .form-textarea {
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,0.25);
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus {
|
||||
box-shadow: 0 0 0 3px var(--accent-dim), inset 0 1px 3px rgba(0,0,0,0.1);
|
||||
}
|
||||
Reference in new issue
Block a user