Compare commits

...
Author SHA1 Message Date
KoalaTrapLord 75b2504470 Add Phase 8 — Multiplayer (Human vs Human + AI) 2026-03-14 00:33:24 -04:00
KoalaTrapLord d817c97a4f Add GUI framework decision and two-phase frontend plan 2026-03-14 00:30:36 -04:00
KoalaTrapLord b947c707e2 Update LLM model to GPT-OSS 20B (Q4_K_M) 2026-03-14 00:23:12 -04:00
KoalaTrapLord c4175cb03a Add Commander GUI + LLM Opponents roadmap 2026-03-14 00:20:41 -04:00
KoalaTrapLord 1f03a359e6 feat(brain): pipe GameResult winner_seat + per-player life totals into flush_log() for RL training 2026-03-14 00:13:50 -04:00
KoalaTrapLord b4734a38eb fix(ui): merge duplicate .stat-card declarations in premium layer 2026-03-14 00:10:14 -04:00
KoalaTrapLord 7fae938f25 feat(rules): add load_weights() / save_weights() for simulation-driven weight learning 2026-03-13 23:58:59 -04:00
KoalaTrapLord 5bff3c4ca0 feat(ui): add premium upgrade layer to global.css 2026-03-13 23:51:47 -04:00
KoalaTrapLord cbcb601192 feat(tests): add pytest infrastructure and test coverage for models, rules, and sim engine (closes #28) 2026-03-13 22:30:40 -04:00
KoalaTrapLord 83feca32cd chore: add requirements.txt for pip install compatibility (closes #29) 2026-03-13 20:53:31 -04:00
KoalaTrapLord 39637855e0 fix(deps): declare all runtime dependencies in pyproject.toml (closes #29) 2026-03-13 20:53:18 -04:00
KoalaTrapLord 19b74d152c feat(ui): 4-player deck slots, seat colors, chart support (Task 2C) 2026-03-13 19:08:53 -04:00
KoalaTrapLord cf0e8b50fe feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:59:25 -04:00
KoalaTrapLord 79b3b9e013 feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:58:33 -04:00
KoalaTrapLord 416b3410f3 feat(api): accept 2-4 decks in batch start, dynamic Java CLI args (Task 2C) 2026-03-13 18:56:58 -04:00
KoalaTrapLord dfb9b31995 feat(ui): 4-player deck grid, seat colors, responsive breakpoints (Task 2C) 2026-03-13 18:55:17 -04:00
KoalaTrapLord 74bd451d5f feat: N-player deepseek engine (Task 2A — Issue #27)
- run_n() accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic for multiplayer combat targeting
- N-player elimination order tracking with finish positions
- Removal scans all opponents for scariest creature
- ML snapshot captures all N players
- Log entries use per-seat boards dict with legacy boardA/boardB for 2-player
2026-03-13 16:58:23 -04:00
KoalaTrapLord 0606bd3c4a feat: N-player engine turn loop (Task 2A — Issue #27)
- engine.py: new run_n() method accepts 2-4 decks; run() wraps for backward compat
- _select_attack_target() heuristic: attack weakest non-eliminated opponent
- N-player elimination order tracking with correct finish positions
- Removal spells scan all opponents for best target
- Game logs use per-seat boards dict with boardA/boardB legacy keys for 2-player
- Tested: 2/3/4-player, elimination order, attack targeting
2026-03-13 16:54:55 -04:00
KoalaTrapLord c1b3ae4eb3 fix: preserve is_commander flag when loading .dck files
Two places lost the commander flag:

1. _load_deck_cards_by_name() .dck fallback — used a flat in_cards
   boolean for [Main], [Deck], and [Commander] sections so it couldn't
   tell which section a card came from. Now tracks current_section and
   sets is_commander=1 for cards in [Commander].

2. parse_dck_file() — stored section='Commander' in the card dict but
   the frontend filters on c.is_commander, not c.section. Now includes
   is_commander: 1/0 alongside the existing section field.
2026-03-13 20:20:18 +00:00
KoalaTrapLord ef57474b67 refactor: adopt Python logging module with named loggers
Replace all print() calls with structured logging using the Python
logging module. This provides:

- Named loggers per subsystem: commander_ai_lab.api, .batch, .sim,
  .coach, .deckgen, .collection, .scanner, .ml, .cache, .pplx
- Dual output: console (StreamHandler) + rotating file (RotatingFileHandler)
  at logs/commander-ai-lab.log (25 MB x 5 backups = 125 MB max)
- Configurable via env vars: CAL_LOG_DIR, CAL_LOG_MAX_MB, CAL_LOG_BACKUP_COUNT
- --verbose / -v flag for DEBUG-level output
- overnight-run.py: separate rotating log at logs/overnight-run.log (10 MB x 5)
- Sub-module loggers in ml/, scanner/, deck_sources/, src/ all propagate
  to the root commander_ai_lab logger
- Stripped redundant [TAG] prefixes from messages (logger name carries context)
- Format: '2026-03-13 14:00:00 INFO  [commander_ai_lab.batch] message'

Files changed: lab_api.py, overnight-run.py, and 11 sub-module .py files
176 total print() calls converted (101 in lab_api.py, 76 in sub-modules)
2026-03-13 18:09:52 +00:00
KoalaTrapLord f3c44559d3 feat: Scryfall response caching with SQLite
- _ScryfallCache class: thread-safe SQLite cache for raw Scryfall JSON
  responses, keyed by name|set_code|collector_number, 7-day TTL
- _fetch_scryfall_api() wrapper: checks cache first, live HTTP on miss,
  caches successful responses + resolves spelling normalization
- Refactored _enrich_from_scryfall() to use cache — all 3 callers
  (collection import, deck card add, re-enrich) benefit automatically
- Rate limiting only on actual HTTP calls, not cache hits
- REST endpoints: GET/DELETE /api/cache/scryfall, POST .../evict
- Fixed VACUUM-inside-transaction bug in clear()
- Configurable TTL via SCRYFALL_CACHE_TTL env var
2026-03-13 15:25:30 +00:00
KoalaTrapLord b35768d03b Phase 2: score_card() num_opponents scaling + enrich_card() determinism fix (#32)
- score_card() now accepts num_opponents: int parameter (default 1)
- Keywords/effects scale with pod size via _MULTI_OPP_SCALING dict
- Multiplayer-specific oracle patterns: 'each opponent', pillowfort, 'each player'
- Board wipe and counter spell bonuses scale with opponent count
- Expanded keyword coverage (double strike, first strike, hexproof, indestructible,
  menace, vigilance, ward, infect, annihilator, cascade, defender)
- Triggered ability bonuses: ETB, attack triggers, dies triggers
- enrich_card() replaced random.randint() with hashlib.md5 name-based hash
  for deterministic CMC estimation across games (#32)
- Backward compatible: existing score_card(card, weights) calls unaffected
2026-03-12 20:22:44 +00:00
29 changed files with 2175 additions and 3372 deletions

No files matched your search

+145
View File
@@ -0,0 +1,145 @@
# 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
+5 -1
View File
@@ -7,6 +7,10 @@ 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:
@@ -26,5 +30,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
print(f" [ARCHIDEKT] Stub: No template decks for '{commander_name}' (not yet implemented)")
log.info(f" Stub: No template decks for '{commander_name}' (not yet implemented)")
return []
+5 -1
View File
@@ -7,6 +7,10 @@ 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:
@@ -27,5 +31,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
print(f" [EDHREC] Stub: No recommendations for '{commander_name}' (not yet implemented)")
log.info(f" Stub: No recommendations for '{commander_name}' (not yet implemented)")
return []
+5 -1
View File
@@ -6,6 +6,10 @@ 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:
@@ -13,5 +17,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.
"""
print(f" [MOXFIELD] Stub: Not yet implemented for '{commander_name}'")
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
+5 -1
View File
@@ -6,6 +6,10 @@ 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:
@@ -13,5 +17,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.
"""
print(f" [MTGGOLDFISH] Stub: Not yet implemented for '{commander_name}'")
log.info(f" Stub: Not yet implemented for '{commander_name}'")
return []
+387 -119
View File
@@ -19,6 +19,8 @@ import asyncio
import csv
import io
import json
import logging
import logging.handlers
import os
import re
import sqlite3
@@ -62,6 +64,61 @@ 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
# ══════════════════════════════════════════════════════════════
@@ -369,10 +426,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) != 3:
raise HTTPException(400, "Exactly 3 decks required")
if len(req.decks) < 2 or len(req.decks) > 4:
raise HTTPException(400, "2-4 decks required")
if not all(req.decks):
raise HTTPException(400, "All 3 deck slots must be filled")
raise HTTPException(400, f"All {len(req.decks)} 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:
@@ -512,7 +569,7 @@ async def list_decks():
})
seen_names.add(db_name.lower())
except Exception as e:
print(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
log.warning(f" WARNING: Failed to load DB decks for /api/lab/decks: {e}")
return {"decks": decks}
@@ -633,7 +690,11 @@ 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})
cards.append({
"quantity": qty, "name": name, "set": set_code,
"section": section,
"is_commander": 1 if section == "Commander" else 0,
})
if section == "Commander":
commander = name
total = sum(c["quantity"] for c in cards)
@@ -719,10 +780,10 @@ def load_precon_index():
if idx_path.exists():
with open(idx_path, "r", encoding="utf-8") as f:
PRECON_INDEX = json.load(f)
print(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
log.info(f" Precons: {len(PRECON_INDEX)} precon decks loaded")
else:
PRECON_INDEX = []
print(f" Precons: index not found at {idx_path}")
log.info(f" Precons: index not found at {idx_path}")
@app.get("/api/lab/precons")
@@ -866,7 +927,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:
print(f" Precons: {len(existing)} decks cached "
log.info(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),
@@ -875,7 +936,7 @@ def download_precon_database(force: bool = False) -> dict:
pass # Fall through to download
# ── Download ─────────────────────────────────────────────
print(" Precons: Downloading full precon database from GitHub...")
log.info(" 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:
@@ -883,7 +944,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}"
print(f" Precons: ERROR — {msg}")
log.error(f" Precons: ERROR — {msg}")
# Fall back to whatever we have locally
if idx_path.exists():
load_precon_index()
@@ -895,7 +956,7 @@ def download_precon_database(force: bool = False) -> dict:
if d.get('type') == 'Commander Deck'
and (d.get('format') or '').lower() == 'commander'
]
print(f" Precons: Found {len(commander_decks)} Commander precon decks")
log.info(f" Precons: Found {len(commander_decks)} Commander precon decks")
# ── Ensure precon-decks dir exists ───────────────────────
PRECON_DIR.mkdir(parents=True, exist_ok=True)
@@ -953,7 +1014,7 @@ def download_precon_database(force: bool = False) -> dict:
json.dump(index, f, indent=2, ensure_ascii=False)
PRECON_INDEX = index
print(f" Precons: {written} .dck files written, index saved")
log.info(f" Precons: {written} .dck files written, index saved")
return {"downloaded": written, "skipped": False, "total": written, "error": None}
@@ -1195,7 +1256,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")
print(f" [Import] Saved .dck: {out_path}")
log_collect.info(f" Saved .dck: {out_path}")
return out_path
@@ -1239,7 +1300,7 @@ async def run_batch_subprocess(
ai_think_time_ms=ai_think_time_ms,
max_queue_depth=max_queue_depth,
)
print(f"[Lab API] Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
log_batch.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
@@ -1248,7 +1309,7 @@ async def run_batch_subprocess(
except Exception as e:
state.error = str(e)
state.running = False
print(f"[Lab API] Batch {state.batch_id} ERROR: {e}")
log_batch.error(f"Batch {state.batch_id} ERROR: {e}")
def _run_process_blocking(state: BatchState, cmd: list[str]):
@@ -1266,9 +1327,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")
print(f"[Lab API] Debug log: {debug_log_path}")
log_batch.warning(f"Debug log: {debug_log_path}")
except Exception as e:
print(f"[Lab API] Could not write debug log header: {e}")
log_batch.warning(f"Could not write debug log header: {e}")
# Ensure Forge subprocesses use Java 17 (Forge crashes on Java 25+)
env = os.environ.copy()
@@ -1278,7 +1339,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', '')
print(f'[Lab API] Using Java 17: {java17}')
log_batch.info(f'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;
@@ -1309,7 +1370,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:
print(f'[Lab API] WATCHDOG: No output for {int(elapsed_since_activity)}s. Killing process.')
log_batch.warning(f'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
@@ -1345,7 +1406,7 @@ def _run_process_blocking(state: BatchState, cmd: list[str]):
except ValueError:
pass
print(f" [{state.batch_id}] {line}")
log_batch.info(f" [{state.batch_id}] {line}")
proc.wait()
@@ -1367,7 +1428,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:
print(f"[Lab API] Could not write debug log output: {e}")
log_batch.warning(f"Could not write debug log output: {e}")
if proc.returncode != 0:
if proc.returncode == -9 or proc.returncode == 137:
@@ -1376,7 +1437,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
print(f"[Lab API] Batch {state.batch_id} completed in {elapsed}ms")
log_batch.info(f"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):
@@ -1386,9 +1447,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:
print(f"[Lab API] Deck reports updated: {', '.join(updated)}")
log_batch.info(f"Deck reports updated: {', '.join(updated)}")
except Exception as rpt_err:
print(f"[Lab API] Deck report generation failed (non-fatal): {rpt_err}")
log_batch.info(f"Deck report generation failed (non-fatal): {rpt_err}")
except Exception as e:
state.error = str(e)
@@ -1451,32 +1512,41 @@ def _load_deck_cards_by_name(deck_name: str) -> list[dict]:
if result:
return result
except Exception as e:
print(f"[DeepSeek Batch] DB lookup failed for '{deck_name}': {e}")
log_sim.error(f"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 = []
in_cards = False # True when inside [Main], [Deck], or [Commander]
current_section = None
with open(dck_path, 'r', encoding='utf-8') as f:
for line in f:
line = line.strip()
low = line.lower()
if low in ('[main]', '[deck]', '[commander]'):
in_cards = True
if low == '[commander]':
current_section = 'commander'
continue
if line.startswith('['):
in_cards = False
elif low in ('[main]', '[deck]'):
current_section = 'main'
continue
if in_cards and line:
elif line.startswith('['):
current_section = None
continue
if current_section 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})
cards.append({
'name': name,
'type_line': '',
'cmc': 0,
'is_commander': is_cmdr,
})
except ValueError:
pass
if cards:
@@ -1752,7 +1822,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)')
print(f'[DeepSeek Batch] Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
except Exception as e:
import traceback
@@ -1855,9 +1925,11 @@ def build_java_command(
java17, "-jar", CFG.lab_jar,
"--forge-jar", CFG.forge_jar,
"--forge-dir", CFG.forge_dir,
"--deck1", decks[0],
"--deck2", decks[1],
"--deck3", decks[2],
]
# Add deck args dynamically (2-4 decks)
for i, deck in enumerate(decks):
cmd.extend([f"--deck{i+1}", deck])
cmd += [
"--games", str(num_games),
"--threads", str(threads),
"--clock", str(clock),
@@ -2015,10 +2087,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}")
print(f" Migration: added column '{col_name}' to collection_entries")
log_collect.info(f" Migration: added column '{col_name}' to collection_entries")
conn.commit()
conn.close()
print(f" Collection DB: {COLLECTION_DB_PATH}")
log_collect.info(f" Collection DB: {COLLECTION_DB_PATH}")
# Fields stored as JSON strings in SQLite that should be returned as parsed arrays
@@ -2488,6 +2560,147 @@ 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
@@ -2671,20 +2884,26 @@ def _parse_csv_content(content: str, source: str, mapping: Optional[dict]) -> li
return rows
def _enrich_from_scryfall(name: str, set_code: str = "", collector_number: str = "") -> dict:
def _fetch_scryfall_api(name: str, set_code: str = "", collector_number: str = "") -> dict:
"""
Fetch card data from Scryfall and return enriched fields dict.
Tries set+collectorNumber first, then exact name lookup.
Returns empty dict on failure.
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.
"""
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)
@@ -2711,6 +2930,27 @@ def _enrich_from_scryfall(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", [])
@@ -2927,6 +3167,31 @@ 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
# ══════════════════════════════════════════════════════════════
@@ -3988,7 +4253,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")
print(f" [DeckBuilder] Exported deck {deck_id} to {out_path}")
log_deckgen.info(f" Exported deck {deck_id} to {out_path}")
return {
"success": True,
@@ -4314,7 +4579,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)")
print(f" [SCAN] Processing {len(image_bytes)} bytes, mode={mode} (Ximilar AI)")
log_scan.info(f" 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)
@@ -4725,7 +4990,7 @@ async def get_card_edhrec(cardId: int):
})
except Exception as e:
print(f" [EDHREC] Error fetching data for '{name}': {e}")
log_deckgen.error(f" Error fetching data for '{name}': {e}")
# Graceful degradation — return empty results
result = {
@@ -5152,7 +5417,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:
print(f'[DeepSeek] Failed to initialize brain: {e}')
log_sim.error(f'Failed to initialize brain: {e}')
return None
return _deepseek_brain
@@ -5651,11 +5916,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", [])
print(f" [DECKGEN] Commander: {commander['name']}, CI: {color_identity}")
log_deckgen.info(f" Commander: {commander['name']}, CI: {color_identity}")
# 2. Load collection
collection = _get_collection_for_colors(color_identity)
print(f" [DECKGEN] {len(collection)} cards in collection match color identity")
log_deckgen.info(f" {len(collection)} cards in collection match color identity")
# 3. Call source adapters (currently stubbed)
template_cards = [] # list of (name, weight, source_name)
@@ -5668,7 +5933,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "archidekt"))
except Exception as e:
print(f" [DECKGEN] Archidekt adapter error: {e}")
log_deckgen.error(f" Archidekt adapter error: {e}")
try:
if sources.use_edhrec:
@@ -5677,7 +5942,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "edhrec"))
except Exception as e:
print(f" [DECKGEN] EDHREC adapter error: {e}")
log_deckgen.error(f" EDHREC adapter error: {e}")
try:
if sources.use_moxfield:
@@ -5686,7 +5951,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "moxfield"))
except Exception as e:
print(f" [DECKGEN] Moxfield adapter error: {e}")
log_deckgen.error(f" Moxfield adapter error: {e}")
try:
if sources.use_mtggoldfish:
@@ -5695,7 +5960,7 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
for tc in td.cards:
template_cards.append((tc.name, tc.quantity, "mtggoldfish"))
except Exception as e:
print(f" [DECKGEN] MTGGoldfish adapter error: {e}")
log_deckgen.error(f" MTGGoldfish adapter error: {e}")
# 4. Build candidate pool from collection, scored by type need
# Build a map: name_lower -> card dict (deduped)
@@ -5883,8 +6148,8 @@ def _generate_deck(req: DeckGenerationRequest) -> dict:
"is_proxy": card.get("is_proxy", False),
})
print(f" [DECKGEN] Generated deck: {len(clean_cards)} cards + commander")
print(f" [DECKGEN] Stats: {stats}")
log_deckgen.info(f" Generated deck: {len(clean_cards)} cards + commander")
log_deckgen.info(f" Stats: {stats}")
return {
"commander": commander,
@@ -6015,11 +6280,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))
print(f" [DECKGEN] Exported .dck file: {dck_path}")
log_deckgen.info(f" Exported .dck file: {dck_path}")
except Exception as e:
print(f" [DECKGEN] Warning: Failed to export .dck file: {e}")
log_deckgen.warning(f" Warning: Failed to export .dck file: {e}")
print(f" [DECKGEN] Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander")
log_deckgen.info(f" Saved deck '{deck_name}' (ID: {deck_id}) with {len(cards)} cards + commander")
result["deck_id"] = deck_id
result["deck_name"] = deck_name
@@ -6098,7 +6363,7 @@ async def deck_generator_commander_search(q: str = ""):
"image_url": image_uris.get("normal", image_uris.get("small", "")),
})
except Exception as e:
print(f" [DECKGEN] Scryfall search error: {e}")
log_deckgen.error(f" Scryfall search error: {e}")
return {"results": results[:20]}
@@ -6268,7 +6533,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', {})
print(f'[pplx-api] tokens: prompt={usage.get("prompt_tokens","?")}, '
log_pplx.debug(f'tokens: prompt={usage.get("prompt_tokens","?")}, '
f'completion={usage.get("completion_tokens","?")}, '
f'model={data.get("model","?")}')
return content
@@ -6583,7 +6848,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:
print(f'[pplx-gen] Scryfall lookup failed for "{commander}": {e}')
log_pplx.error(f'Scryfall lookup failed for "{commander}": {e}')
# Build collection summary
collection_block = ''
@@ -6758,7 +7023,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:
print(f'[deck-gen-v3] Error: {e}')
log_deckgen.error(f'Error: {e}')
import traceback
traceback.print_exc()
raise HTTPException(500, f'Deck generation failed: {str(e)}')
@@ -6865,19 +7130,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))
print(f" [DeckGenV3] Exported .dck: {dck_path}")
log_deckgen.info(f" Exported .dck: {dck_path}")
except Exception as e:
print(f" [DeckGenV3] .dck export failed: {e}")
log_deckgen.error(f" .dck export failed: {e}")
result['deck_id'] = deck_id
result['deck_name'] = deck_name
print(f" [DeckGenV3] Committed deck '{deck_name}' (ID: {deck_id})")
log_deckgen.info(f" 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:
print(f'[deck-gen-v3] Commit error: {e}')
log_deckgen.error(f'Commit error: {e}')
import traceback
traceback.print_exc()
raise HTTPException(500, f'Deck generation/commit failed: {str(e)}')
@@ -7083,22 +7348,22 @@ def init_coach_service():
from coach.config import EMBEDDINGS_NPZ
if EMBEDDINGS_NPZ.exists():
_coach_embeddings.load()
print(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
log_coach.info(f" Coach: Embeddings loaded ({_coach_embeddings.card_count} cards)")
else:
print(" Coach: Embeddings not yet downloaded (will download on first use)")
log_coach.warning(" Coach: Embeddings not yet downloaded (will download on first use)")
except Exception as e:
print(f" Coach: Embeddings load failed: {e}")
log_coach.error(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"):
print(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
log_coach.info(f" Coach LLM: Connected ({llm_status.get('active_model', 'unknown')})")
else:
print(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
log_coach.warning(f" Coach LLM: Not connected (start LM Studio on 192.168.0.122:1234)")
print(" Coach: Service initialized")
log_coach.info(" Coach: Service initialized")
# Initialize V3 Deck Generator (Perplexity)
_deck_gen_v3_error = None
@@ -7117,21 +7382,21 @@ def init_coach_service():
db_conn_factory=_get_db_conn,
embedding_index=_coach_embeddings,
)
print(f" Deck Gen V3: Initialized (model: {DECK_GEN_MODEL})")
log_deckgen.info(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"
print(f" Deck Gen V3: {_deck_gen_v3_error}")
log_deckgen.error(f" Deck Gen V3: {_deck_gen_v3_error}")
_deck_gen_v3 = None
except Exception as e:
_deck_gen_v3_error = str(e)
print(f" Deck Gen V3: Failed to initialize: {e}")
log_deckgen.error(f" Deck Gen V3: Failed to initialize: {e}")
_deck_gen_v3 = None
else:
_deck_gen_v3_error = "PPLX_API_KEY not set"
print(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
log_deckgen.info(" Deck Gen V3: Skipped (no PPLX_API_KEY)")
except Exception as e:
print(f" Coach: Failed to initialize: {e}")
log_coach.error(f" Coach: Failed to initialize: {e}")
_coach_service = None
@@ -7234,7 +7499,7 @@ async def coach_run_session(deck_id: str, body: CoachRequestBody = None):
try:
_coach_embeddings.load(force_download=True)
except Exception as e:
print(f" Coach: Embeddings download failed: {e}")
log_coach.error(f" Coach: Embeddings download failed: {e}")
goals = None
if body and body.goals:
@@ -7249,7 +7514,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:
print(f" Coach: Fallback report build failed for '{deck_id}': {e}")
log_coach.error(f" Coach: Fallback report build failed for '{deck_id}': {e}")
try:
session = await _coach_service.run_coaching_session(deck_id, goals, fallback_report=fallback_report)
@@ -7805,11 +8070,11 @@ def _get_policy_service():
from ml.serving.policy_server import PolicyInferenceService
_policy_service = PolicyInferenceService()
if _policy_service.load():
print(f"[ML] Policy model loaded on {_policy_service.device}")
log_ml.info(f"Policy model loaded on {_policy_service.device}")
else:
print(f"[ML] Policy model not available: {_policy_service._load_error}")
log_ml.error(f"Policy model not available: {_policy_service._load_error}")
except Exception as e:
print(f"[ML] Policy service init failed: {e}")
log_ml.error(f"Policy service init failed: {e}")
return _policy_service
@@ -7969,7 +8234,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..."
print("[ML Train] Building dataset (loading embeddings, may auto-download)...")
log_ml.info("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)
@@ -7981,14 +8246,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"
print(f"[ML Train] Dataset built: {total_samples} samples")
log_ml.info(f"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)..."
print(f"[ML Train] Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
log_ml.info(f"Starting training: {epochs} epochs, lr={lr}, bs={batch_size}")
import numpy as np
import torch
@@ -8060,13 +8325,13 @@ def _run_training_pipeline(
"device": device,
}
_training_state["message"] = f"Training complete! Best val acc: {summary.get('best_val_accuracy', 0):.1%}"
print(f"[ML Train] Complete: {summary.get('best_val_accuracy', 0):.1%} val accuracy")
log_ml.info(f"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"))
print("[ML Train] Policy server reloaded with new checkpoint")
log_ml.info("Policy server reloaded with new checkpoint")
elif not _policy_service_init_attempted:
_policy_service_init_attempted = False # Allow re-init with new model
@@ -8076,7 +8341,7 @@ def _run_training_pipeline(
_training_state["running"] = False
_training_state["error"] = str(e)
_training_state["message"] = f"Training failed: {e}"
print(f"[ML Train] ERROR: {e}")
log_ml.error(f"ERROR: {e}")
traceback.print_exc()
@@ -8485,6 +8750,8 @@ 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()
@@ -8528,17 +8795,18 @@ def load_commander_meta():
try:
with open(meta_path, "r", encoding="utf-8") as f:
COMMANDER_META = json.load(f)
print(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
log.info(f" Meta: Loaded {len(COMMANDER_META)} commanders from {meta_path}")
return
except Exception as e:
print(f" WARNING: Failed to load commander-meta.json: {e}")
log.warning(f" WARNING: Failed to load commander-meta.json: {e}")
COMMANDER_META = BUILTIN_COMMANDERS
print(f" Meta: {len(COMMANDER_META)} built-in commanders")
log.info(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
@@ -8548,21 +8816,21 @@ def main():
CFG.ximilar_api_key = args.ximilar_key
CFG.pplx_api_key = args.pplx_key
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)'}")
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)'}")
j17 = get_java17()
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")
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")
load_commander_meta()
download_precon_database() # Auto-downloads all 163+ Commander precons on first run
@@ -8571,27 +8839,27 @@ def main():
# Ximilar API key check
if not CFG.ximilar_api_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")
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")
if not CFG.forge_jar:
print("WARNING: --forge-jar not set. /api/lab/start will fail.")
log.warning("WARNING: --forge-jar not set. /api/lab/start will fail.")
if not CFG.lab_jar:
print("WARNING: Lab JAR not found. Build with: mvn package -DskipTests")
log.warning("WARNING: Lab JAR not found. Build with: mvn package -DskipTests")
if not CFG.forge_decks_dir:
print("WARNING: Commander decks dir not found. /api/lab/decks will return empty.")
log.warning("WARNING: Commander decks dir not found. /api/lab/decks will return empty.")
print()
print(f" Starting server on http://localhost:{CFG.port}")
log.info("")
log.info(f" Starting server on http://localhost:{CFG.port}")
if _spa_dir.exists():
print(f" Web UI: http://localhost:{CFG.port}/ (React SPA)")
print(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training")
log.info(f" Web UI: http://localhost:{CFG.port}/ (React SPA)")
log.info(f" Routes: / (Batch Sim), /collection, /decks, /autogen, /simulator, /coach, /training")
else:
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()
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("")
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=CFG.port, log_level="info")
+5 -5
View File
@@ -240,12 +240,12 @@ def print_label_distribution(labels: np.ndarray) -> Dict[str, int]:
dist[action.value] = count
total = len(labels)
print(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
print("─" * 43)
log.info(f"\n{'Action':<25} {'Count':>8} {'Pct':>8}")
log.info("─" * 43)
for action_name, count in sorted(dist.items(), key=lambda x: -x[1]):
pct = (count / total * 100) if total > 0 else 0
print(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
print(f"{'─' * 43}")
print(f" {'TOTAL':<23} {total:>8}")
log.info(f" {action_name:<23} {count:>8} {pct:>7.1f}%")
log.info(f"{'─' * 43}")
log.info(f" {'TOTAL':<23} {total:>8}")
return dist
+8 -8
View File
@@ -154,17 +154,17 @@ def build_dataset(
# Print game outcome distribution
unique_outcomes, outcome_counts = np.unique(outcomes, return_counts=True)
print(f"\n{'Outcome':<25} {'Count':>8}")
print("─" * 35)
log.info(f"\n{'Outcome':<25} {'Count':>8}")
log.info("─" * 35)
for o, c in sorted(zip(unique_outcomes, outcome_counts), key=lambda x: -x[1]):
print(f" {str(o):<23} {c:>8}")
log.info(f" {str(o):<23} {c:>8}")
# Print playstyle distribution
unique_styles, style_counts = np.unique(playstyles, return_counts=True)
print(f"\n{'Playstyle':<25} {'Count':>8}")
print("─" * 35)
log.info(f"\n{'Playstyle':<25} {'Count':>8}")
log.info("─" * 35)
for s, c in sorted(zip(unique_styles, style_counts), key=lambda x: -x[1]):
print(f" {str(s):<23} {c:>8}")
log.info(f" {str(s):<23} {c:>8}")
return {
"states": states,
@@ -288,7 +288,7 @@ def main():
max_samples=args.max_samples,
)
if not dataset:
print("\nNo data to save. Run simulations with --ml-log first.")
log.info("\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")
print("\nDataset build complete.")
log.info("\nDataset build complete.")
if __name__ == "__main__":
+6 -2
View File
@@ -16,6 +16,10 @@ 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:
@@ -25,7 +29,7 @@ try:
TORCH_AVAILABLE = True
except ImportError:
TORCH_AVAILABLE = False
print("[ML] PyTorch not installed. Install with: pip install torch")
log.warning("PyTorch not installed. Install with: pip install torch")
import numpy as np
@@ -204,7 +208,7 @@ def save_checkpoint(
"dropout": TRAINING_CONFIG.dropout,
},
}, path)
print(f"[ML] Checkpoint saved: {path}")
log.info(f"Checkpoint saved: {path}")
def load_checkpoint(path: str, device: str = "cpu"):
+26 -27
View File
@@ -49,8 +49,7 @@ from ml.training.policy_network import (
PolicyNetwork, save_checkpoint, load_checkpoint,
)
logging.basicConfig(level=logging.INFO, format="[%(name)s] %(message)s")
logger = logging.getLogger("ml.trainer")
logger = logging.getLogger("commander_ai_lab.ml.training")
# ══════════════════════════════════════════════════════════
@@ -502,40 +501,40 @@ def evaluate_model(
def print_evaluation(results: Dict):
"""Pretty-print evaluation results."""
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)
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)
for action_name, m in results["per_class"].items():
print(
logger.info(
f" {action_name:<20} {m['support']:>8} "
f"{m['precision']:>7.3f} {m['recall']:>7.3f} {m['f1']:>7.3f}"
)
print(" " + "-" * 55)
logger.info(" " + "-" * 55)
# Confusion matrix
print()
print(" Confusion Matrix (rows=true, cols=predicted):")
logger.info("")
logger.info(" 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}"
print(header)
logger.info(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}"
print(row_str)
print()
logger.info(row_str)
logger.info("")
# ══════════════════════════════════════════════════════════
@@ -605,8 +604,8 @@ def main():
args = parser.parse_args()
if not TORCH_AVAILABLE:
print("PyTorch not installed. Install with:")
print(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
logger.info("PyTorch not installed. Install with:")
logger.info(" pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121")
sys.exit(1)
device = args.device or get_best_device()
@@ -621,13 +620,13 @@ def main():
if args.eval_only:
# Evaluation only
if not os.path.exists(test_path):
print(f"Test data not found: {test_path}")
print("Run: python -m ml.scripts.ml_cli build")
logger.info(f"Test data not found: {test_path}")
logger.info("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):
print(f"Checkpoint not found: {ckpt_path}")
logger.info(f"Checkpoint not found: {ckpt_path}")
sys.exit(1)
test_states, test_labels = load_npz_split(test_path)
@@ -641,15 +640,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)
print(f" Results saved: {eval_path}")
logger.info(f" Results saved: {eval_path}")
return
# Training mode
for path, label in [(train_path, "train"), (val_path, "val")]:
if not os.path.exists(path):
print(f"{label} data not found: {path}")
print("Build the dataset first:")
print(" python -m ml.scripts.ml_cli build --results-dir results/")
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/")
sys.exit(1)
train_states, train_labels = load_npz_split(train_path)
+121 -79
View File
@@ -22,11 +22,15 @@ 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 ──────────────────────────────────────────────
@@ -51,10 +55,48 @@ DEFAULT_CONFIG = {
}
def log(msg):
ts = datetime.now().strftime("%H:%M:%S")
print(f"[{ts}] {msg}")
sys.stdout.flush()
# ── 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 api_get(base_url, path):
@@ -129,8 +171,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(f" Time limit reached while batch {batch_id} was running.")
log(f" The batch will continue on the server but we're moving on.")
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.")
return {"completed": 0, "timed_out": True}
try:
@@ -144,12 +186,12 @@ def wait_for_batch(base_url, batch_id, engine="deepseek", deadline=None):
elapsed = status.get("elapsedMs", 0)
if error:
log(f" Batch {batch_id} ERROR: {error}")
log.error(f" Batch {batch_id} ERROR: {error}")
return None
if not running and completed > 0:
elapsed_s = elapsed / 1000 if elapsed else 0
log(f" Batch {batch_id} complete: {completed} games in {elapsed_s:.0f}s")
log.info(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
@@ -160,15 +202,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(f" Batch {batch_id} never started. Aborting.")
log.info(f" Batch {batch_id} never started. Aborting.")
return None
# Progress log with ETA
if deadline:
mins_left = (deadline - time.time()) / 60
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s) — {mins_left:.0f}min left in run")
else:
log(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
log.info(f" Progress: {completed}/{total} games ({elapsed / 1000:.0f}s)")
# Track stall
if completed == last_completed:
@@ -180,9 +222,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(f" Lost connection to server after 10 retries. Aborting batch.")
log.info(f" Lost connection to server after 10 retries. Aborting batch.")
return None
log(f" Connection error (retry {consecutive_errors}/10): {e}")
log.info(f" Connection error (retry {consecutive_errors}/10): {e}")
time.sleep(poll_interval)
@@ -214,26 +256,26 @@ def wait_for_training(base_url):
error = status.get("error")
if error:
log(f" Training ERROR: {error}")
log.error(f" Training ERROR: {error}")
return False
if not running and phase in ("done", "idle") and epoch > 0:
result = status.get("result", {})
log(f" Training complete!")
log.info(f" Training complete!")
if result:
log(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log(f" Model saved: {result.get('model_path', '?')}")
log.info(f" Final accuracy: {result.get('best_val_accuracy', '?')}")
log.info(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(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
log.info(f" Training: epoch {epoch}/{total} — {message} (val_acc: {val_acc})")
else:
log(f" [{phase}] {message}")
log.info(f" [{phase}] {message}")
except Exception as e:
log(f" Connection error: {e}")
log.info(f" Connection error: {e}")
time.sleep(poll_interval)
@@ -253,46 +295,46 @@ def run_overnight(cfg):
"""Main overnight pipeline."""
base_url = cfg["lab_url"]
log("=" * 60)
log(" Commander AI Lab — Overnight AFK Runner")
log("=" * 60)
log.info("=" * 60)
log.info(" Commander AI Lab — Overnight AFK Runner")
log.info("=" * 60)
# ── Verify server ────────────────────────────────────────
log("Checking lab server...")
log.info("Checking lab server...")
if not check_server(base_url):
log(f"ERROR: Cannot reach lab server at {base_url}")
log("Start the lab first: start-lab.bat")
log.error(f"ERROR: Cannot reach lab server at {base_url}")
log.info("Start the lab first: start-lab.bat")
return False
log(f"Server OK at {base_url}")
log.info(f"Server OK at {base_url}")
# ── Get decks ─────────────────────────────────────────────
available_decks = get_available_decks(base_url)
log(f"Found {len(available_decks)} decks: {', '.join(available_decks[:5])}{'...' if len(available_decks) > 5 else ''}")
log.info(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: No decks available. Create decks in the Deck Builder first.")
log.error("ERROR: No decks available. Create decks in the Deck Builder first.")
return False
log(f"Using DeepSeek engine with {len(decks)} deck(s)")
log.info(f"Using DeepSeek engine with {len(decks)} deck(s)")
else:
decks = cfg["java_decks"]
if len(decks) != 3:
log("ERROR: Java engine requires exactly 3 deck names.")
log("Set java_decks in the config or use --decks deck1 deck2 deck3")
log.error("ERROR: Java engine requires exactly 3 deck names.")
log.info("Set java_decks in the config or use --decks deck1 deck2 deck3")
return False
log(f"Using Java/Forge engine with decks: {decks}")
log.info(f"Using Java/Forge engine with decks: {decks}")
# ── ML logging ────────────────────────────────────────────
log("Enabling ML logging...")
log.info("Enabling ML logging...")
try:
api_post(base_url, "/api/ml/toggle?enable=true", {})
log("ML logging enabled")
log.info("ML logging enabled")
except Exception as e:
log(f"WARNING: Could not enable ML logging: {e}")
log("(DeepSeek batches always log ML data regardless)")
log.warning(f"WARNING: Could not enable ML logging: {e}")
log.info("(DeepSeek batches always log ML data regardless)")
# Try alternate form
try:
api_get(base_url, "/api/ml/toggle?enable=true")
@@ -301,7 +343,7 @@ def run_overnight(cfg):
# ── Initial data stats ────────────────────────────────────
decisions_before, files_before = get_ml_data_summary(base_url)
log(f"Current ML data: {decisions_before} decisions in {files_before} files")
log.info(f"Current ML data: {decisions_before} decisions in {files_before} files")
# ── Run batches ───────────────────────────────────────────
games_per_batch = cfg["games_per_batch"]
@@ -315,32 +357,32 @@ def run_overnight(cfg):
total_games = 0
total_decisions = 0
log("")
log(f"Starting batch loop:")
log.info("")
log.info(f"Starting batch loop:")
if num_batches > 0:
log(f" Mode: {num_batches} batches × {games_per_batch} games")
log.info(f" Mode: {num_batches} batches × {games_per_batch} games")
else:
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("")
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("")
while True:
# Check stopping conditions
if num_batches > 0 and batch_num >= num_batches:
log(f"Reached target of {num_batches} batches. Stopping.")
log.info(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(f"Time limit approaching ({remaining:.0f}s left). Stopping sim loop.")
log.info(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(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
log.info(f"── Batch {batch_num} ── (elapsed: {elapsed_str}, remaining: {remaining_str})")
try:
if engine == "deepseek":
@@ -352,12 +394,12 @@ def run_overnight(cfg):
)
if not batch_id:
log(" ERROR: Failed to start batch (no batchId returned)")
log(" Waiting 60s before retry...")
log.error(" ERROR: Failed to start batch (no batchId returned)")
log.info(" Waiting 60s before retry...")
time.sleep(60)
continue
log(f" Started batch {batch_id}")
log.info(f" Started batch {batch_id}")
result = wait_for_batch(base_url, batch_id, engine, deadline=deadline)
if result:
@@ -366,19 +408,19 @@ def run_overnight(cfg):
for deck in result.get("decks", []):
wr = deck.get("winRate", "?")
name = deck.get("deckName", "?")
log(f" {name}: {wr}% WR")
log.info(f" {name}: {wr}% WR")
else:
log(" Batch failed, continuing to next...")
log.info(" Batch failed, continuing to next...")
except Exception as e:
log(f" ERROR starting batch: {e}")
log(" Waiting 60s before retry...")
log.error(f" ERROR starting batch: {e}")
log.info(" Waiting 60s before retry...")
time.sleep(60)
continue
# Pause between batches
if pause_sec > 0:
log(f" Cooling down {pause_sec}s...")
log.info(f" Cooling down {pause_sec}s...")
time.sleep(pause_sec)
# ── Summary ───────────────────────────────────────────────
@@ -387,26 +429,26 @@ def run_overnight(cfg):
new_decisions = decisions_after - decisions_before
new_files = files_after - files_before
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("")
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("")
# ── Auto-train ────────────────────────────────────────────
if cfg["auto_train"]:
if decisions_after < 100:
log("Skipping training — too few decisions (need at least 100)")
log.info("Skipping training — too few decisions (need at least 100)")
return True
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']}")
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']}")
try:
start_training(
@@ -416,21 +458,21 @@ def run_overnight(cfg):
batch_size=cfg["train_batch_size"],
patience=cfg["train_patience"],
)
log("Training started, monitoring progress...")
log.info("Training started, monitoring progress...")
success = wait_for_training(base_url)
if success:
log("")
log("=" * 60)
log(" TRAINING COMPLETE — model updated!")
log("=" * 60)
log.info("")
log.info("=" * 60)
log.info(" TRAINING COMPLETE — model updated!")
log.info("=" * 60)
else:
log("Training finished with errors. Check the Training tab for details.")
log.info("Training finished with errors. Check the Training tab for details.")
except Exception as e:
log(f"ERROR starting training: {e}")
log.error(f"ERROR starting training: {e}")
return False
log("")
log("Overnight run finished. You can close this window.")
log.info("")
log.info("Overnight run finished. You can close this window.")
return True
+22
View File
@@ -10,8 +10,30 @@ 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"
+7
View File
@@ -0,0 +1,7 @@
# 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
+6 -2
View File
@@ -13,6 +13,10 @@ 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
@@ -160,7 +164,7 @@ def _ximilar_to_scan_result(xr: XimilarResult,
try:
scryfall_data = scryfall_lookup(xr.name)
except Exception as e:
print(f" [SCAN] Scryfall lookup failed for '{xr.name}': {e}")
log.error(f" Scryfall lookup failed for '{xr.name}': {e}")
if scryfall_data and scryfall_data.get("object") != "error":
result.scryfall_id = scryfall_data.get("id", "")
@@ -181,6 +185,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
print(f" [SCAN] No Scryfall match for '{xr.name}' — using Ximilar data only")
log.info(f" No Scryfall match for '{xr.name}' — using Ximilar data only")
return result
+8 -4
View File
@@ -13,6 +13,10 @@ 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"
@@ -82,7 +86,7 @@ def identify_cards(image_bytes: bytes, api_key: str,
)
try:
print(f" [XIMILAR] Sending {len(image_bytes):,} bytes to API (analyze_all={analyze_all})")
log.info(f" 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:
@@ -168,12 +172,12 @@ def _parse_response(resp_data: dict) -> List[XimilarResult]:
if not results:
results.append(XimilarResult(error="Ximilar could not identify any cards"))
print(f" [XIMILAR] Identified {len(results)} card(s)")
log.info(f" Identified {len(results)} card(s)")
for r in results:
if r.name:
print(f" [XIMILAR] - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
log.info(f" - {r.name} ({r.set_code}) conf={r.confidence:.2f}")
elif r.error:
print(f" [XIMILAR] - ERROR: {r.error}")
log.error(f" - ERROR: {r.error}")
return results
+30 -26
View File
@@ -13,6 +13,10 @@ Functions:
"""
from __future__ import annotations
import logging
log = logging.getLogger("commander_ai_lab.lab.experiments")
import time
from typing import Optional
@@ -238,27 +242,27 @@ def _generate_training_deck() -> list[Card]:
def _print_stats(stats: dict) -> None:
"""Pretty-print simulation stats."""
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()
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("")
def _cli_main() -> None:
@@ -294,15 +298,15 @@ def _run_smoke_test() -> None:
"Harmonize", "Read the Bones", "Phyrexian Arena",
]
print("Building test deck (Korvold Aristocrats)...")
log.info("Building test deck (Korvold Aristocrats)...")
test_deck = build_deck(test_decklist)
print(f" Deck size: {len(test_deck)} cards")
log.info(f" Deck size: {len(test_deck)} cards")
print("\nRunning single game...")
log.info("\nRunning single game...")
single = run_single_game(test_deck, name_a="Korvold")
print(f" Winner: {single['winner_name']} in {single['turns']} turns")
log.info(f" Winner: {single['winner_name']} in {single['turns']} turns")
print("\nRunning Monte Carlo simulation (10 games)...")
log.info("\nRunning Monte Carlo simulation (10 games)...")
stats = run_simulation(test_deck, num_games=10, name_a="Korvold")
_print_stats(stats)
+152 -104
View File
@@ -18,6 +18,8 @@ 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
@@ -115,7 +117,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)
@@ -127,7 +129,6 @@ 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":
@@ -138,14 +139,13 @@ 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 # colorless
mana_by_color["C"] += 1
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,7 +157,6 @@ 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")
@@ -169,22 +168,17 @@ 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 — with keywords and oracle for synergy ───────
# ── My Board ──
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:
@@ -195,7 +189,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
@@ -203,10 +197,7 @@ 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()
@@ -221,7 +212,6 @@ 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:
@@ -233,7 +223,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]]
@@ -243,7 +233,6 @@ 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}
@@ -251,7 +240,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,
@@ -274,11 +263,9 @@ 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" # default
cmd_zone = "library"
for c in me.hand:
if c.name.lower() == cmd_name_lower:
cmd_zone = "hand"
@@ -291,26 +278,18 @@ 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"]
@@ -340,7 +319,7 @@ def _short_type(type_line: str) -> str:
def build_deck_context(
full_deck: list, # list[Card] — the full original deck
full_deck: list,
commander_name: str = "",
color_identity: list[str] | None = None,
archetype: str = "midrange",
@@ -360,7 +339,6 @@ 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] = []
@@ -371,32 +349,25 @@ 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 # one label per card
break
avg_cmc = round(total_cmc / max(non_land_count, 1), 1)
ctx["deck_summary"] = {
"card_types": type_counts,
"avg_cmc": avg_cmc,
@@ -406,10 +377,8 @@ def build_deck_context(
"draw_count": len(roles["draw"]),
"archetype": archetype,
}
ctx["combo_pieces"] = combo_pieces[:15] # cap to save tokens
ctx["combo_pieces"] = combo_pieces[:15]
ctx["key_cards"] = list(set(key_cards))[:15]
return ctx
@@ -417,20 +386,18 @@ def build_deck_context(
# Action Schema (Step 3)
# ══════════════════════════════════════════════════════════════
# Valid actions the LLM can return
VALID_ACTIONS = [
"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)
"play_land",
"cast_creature",
"cast_removal",
"cast_board_wipe",
"cast_ramp",
"cast_spell",
"attack_all",
"attack_safe",
"hold",
]
# 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",
@@ -529,14 +496,19 @@ class DeepSeekBrain:
"""
LLM-powered decision engine for the AI opponent.
Sends game state to a local DeepSeek model and parses the action response.
Sends game state to a local DeepSeek/GPT-OSS 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
@@ -544,6 +516,21 @@ 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:
@@ -555,7 +542,6 @@ 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
@@ -569,20 +555,20 @@ class DeepSeekBrain:
def choose_action(
self,
sim_state, # SimState
player_index: int, # Which player is the AI
sim_state,
player_index: int,
turn: int,
available_mana: int | None = None,
deck_context: dict | None = None, # Full deck intelligence from build_deck_context()
deck_context: dict | None = None,
) -> dict:
"""
Choose an action for the AI player.
Returns:
{
"action": str, # One of VALID_ACTIONS
"target_card": str, # Optional card name
"reasoning": str, # Brief explanation
"action": str,
"target_card": str,
"reasoning": str,
"source": "deepseek" | "heuristic" | "cache",
"latency_ms": float,
}
@@ -590,12 +576,10 @@ 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:
@@ -605,7 +589,6 @@ 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")
@@ -614,13 +597,11 @@ 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)
self._log_decision(snapshot, result, player_index)
self._total_latency_ms += result["latency_ms"]
return result
@@ -633,11 +614,9 @@ 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": [
@@ -658,7 +637,6 @@ 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")
@@ -672,18 +650,12 @@ 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)
@@ -691,7 +663,6 @@ 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 {
@@ -701,16 +672,14 @@ 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" # safe default
action = "hold"
return {
"action": action,
@@ -728,18 +697,15 @@ 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:
@@ -748,38 +714,31 @@ 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:
@@ -802,13 +761,10 @@ 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
@@ -817,10 +773,17 @@ class DeepSeekBrain:
# ── Decision logging ──
def _log_decision(self, snapshot: dict, result: dict):
"""Log decision for future training data."""
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.
"""
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"),
@@ -829,14 +792,96 @@ 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):
"""Write accumulated decision log to JSONL file."""
if not self._decision_log:
return
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.
"""
if not self._decision_log:
return None
# ── 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"
@@ -859,7 +904,9 @@ 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,
@@ -871,4 +918,5 @@ 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,
}
+139 -64
View File
@@ -5,11 +5,16 @@ 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.
@@ -77,14 +82,40 @@ class DeepSeekGameEngine:
color_identity: list[str] | None = None,
win_rate: float | None = None,
) -> GameResult:
"""Run a single game. The AI player uses DeepSeek for decisions."""
sim = self._create_state(deck_a, deck_b, name_a, name_b)
"""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)
game_log: list[dict] = []
elimination_order: list[int] = []
gid = game_id or str(uuid.uuid4())[:12]
turn_decisions: list[dict] = [] # raw (snapshot, action_type) pairs
turn_decisions: list[dict] = []
# Build deck intelligence context once at game start
ai_deck = deck_b if self.ai_player_index == 1 else deck_a
ai_idx = min(self.ai_player_index, n - 1)
ai_deck = decks[ai_idx]
self._deck_context = build_deck_context(
full_deck=ai_deck,
commander_name=commander_name,
@@ -97,7 +128,7 @@ class DeepSeekGameEngine:
for turn in range(sim.max_turns):
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(2):
for pi in range(len(sim.players)):
p = sim.players[pi]
if p.eliminated:
continue
@@ -118,7 +149,7 @@ class DeepSeekGameEngine:
pre_snapshot = self._capture_ml_snapshot(sim, pi, turn)
# ── Decide: LLM or Heuristic? ──
action_type = "pass" # default
action_type = "pass"
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:
@@ -141,17 +172,29 @@ class DeepSeekGameEngine:
c.tapped = False
if self.record_log:
turn_entry["phases"].append({
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 = {
"player": p.name,
"playerId": pi,
"events": phase_events,
"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()],
})
"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!")
# Check game over
alive = [p for p in sim.players if not p.eliminated]
@@ -159,21 +202,16 @@ 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, name_a, name_b)
result = self._build_result(sim, final_turn, elimination_order)
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:
outcome = "win" if result.winner == 0 else "loss" if result.winner == 1 else "draw"
ai_won = result.winner_seat == self.ai_player_index
outcome = "win" if ai_won else "loss"
for td in turn_decisions:
td["game_outcome"] = outcome
self.ml_decisions.extend(turn_decisions)
@@ -216,7 +254,7 @@ class DeepSeekGameEngine:
if self.record_log:
events.append(f"[AI Brain: {source}] Action: {action} "
f"{'→ ' + target_card_name if target_card_name else ''} "
f"{'\u2192 ' + target_card_name if target_card_name else ''} "
f"({reasoning})")
# ── Phase 1: Land drop ──
@@ -242,7 +280,7 @@ class DeepSeekGameEngine:
elif action == "hold":
# LLM chose to hold — no combat
if self.record_log:
events.append("Holds — no attack")
events.append("Holds \u2014 no attack")
else:
# Default: still attack if we have creatures and it makes sense
self._resolve_combat_heuristic(sim, pi, turn, events)
@@ -435,21 +473,23 @@ class DeepSeekGameEngine:
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal
# Handle removal — target the scariest creature across all opponents
if card.is_removal:
p.stats.removal_used += 1
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]
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}")
events.append(f"Cast {card.name} (removal) \u2014 destroyed {killed.name}")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
@@ -470,7 +510,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) — destroyed {len(wiped_names)} creatures")
events.append(f"Cast {card.name} (board wipe) \u2014 destroyed {len(wiped_names)} creatures")
else:
sim.add_to_battlefield(pi, card)
@@ -488,6 +528,23 @@ 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
# ──────────────────────────────────────────────────────────
@@ -502,11 +559,10 @@ class DeepSeekGameEngine:
attack_safe: only evasive/large creatures
"""
p = sim.players[pi]
opp_idx = 1 - pi
opp = sim.players[opp_idx]
if opp.eliminated:
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
opp = sim.players[opp_idx]
my_creatures = [
c for c in sim.get_battlefield(pi)
@@ -526,7 +582,6 @@ 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):
@@ -538,7 +593,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 ({mode_label}): {', '.join(atk_names)}")
events.append(f"Attacks {opp.name} ({mode_label}): {', '.join(atk_names)}")
# Resolve blocking and damage (same logic as base engine)
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
@@ -546,13 +601,12 @@ class DeepSeekGameEngine:
def _resolve_combat_heuristic(
self, sim: SimState, pi: int, turn: int, events: list[str] | None
) -> bool:
"""Heuristic combat (same as base GameEngine._resolve_combat). Returns True if attacked."""
"""Heuristic combat (N-player ready). Returns True if attacked."""
p = sim.players[pi]
opp_idx = 1 - pi
opp = sim.players[opp_idx]
if opp.eliminated:
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return False
opp = sim.players[opp_idx]
my_creatures = [
c for c in sim.get_battlefield(pi)
@@ -596,7 +650,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 with: {', '.join(atk_names)}")
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
self._resolve_damage(sim, pi, attackers, opp_blockers, turn, events)
return len(attackers) > 0
@@ -612,7 +666,9 @@ class DeepSeekGameEngine:
):
"""Resolve blocking and combat damage."""
p = sim.players[pi]
opp_idx = 1 - pi
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
opp = sim.players[opp_idx]
w = self.weights
@@ -693,12 +749,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 → state_encoder → labeler).
training pipeline (dataset_builder \u2192 state_encoder \u2192 labeler).
Returns a dict compatible with ml-decisions-*.jsonl format.
"""
players_data = []
for seat in range(2):
for seat in range(len(sim.players)):
p = sim.players[seat]
# Count creatures and lands on battlefield
seat_bf = sim.get_battlefield(seat)
@@ -722,7 +778,7 @@ class DeepSeekGameEngine:
c.name for c in seat_bf
if not c.is_land()
]
# Command zone — not explicitly tracked in Python sim, use empty
# Command zone \u2014 not explicitly tracked in Python sim, use empty
command_zone_names = []
players_data.append({
@@ -739,7 +795,7 @@ class DeepSeekGameEngine:
"command_zone": command_zone_names,
})
# Determine game phase — Python sim is simplified, treat as main_1
# Determine game phase \u2014 Python sim is simplified, treat as main_1
phase = "main_1"
return {
@@ -788,11 +844,12 @@ class DeepSeekGameEngine:
)
def _create_state(
self, deck_a: list[Card], deck_b: list[Card],
name_a: str, name_b: str,
self, decks: list[list[Card]],
names: list[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([(deck_a, name_a), (deck_b, name_b)]):
for idx, (deck, name) in enumerate(zip(decks, names)):
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
hand = cards[:7]
@@ -811,17 +868,35 @@ class DeepSeekGameEngine:
def _build_result(
self, sim: SimState, final_turn: int,
name_a: str, name_b: str,
elimination_order: list[int] | None = None,
) -> GameResult:
pa = sim.players[0]
pb = sim.players[1]
"""Determine winner and build result object (N-player)."""
if elimination_order is None:
elimination_order = []
n = len(sim.players)
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
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)
else:
winner = 0 if pa.life >= pb.life else 1
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
player_results = []
for seat, p in enumerate(sim.players):
@@ -830,7 +905,7 @@ class DeepSeekGameEngine:
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=1 if seat == winner else 2,
finish_position=finish.get(seat, n),
stats=p.stats,
))
+129 -70
View File
@@ -4,12 +4,15 @@ Commander AI Lab — Headless Game Engine
A headless Commander game simulator for Monte Carlo analysis.
Faithful port of dtSimGame() / dtCreatePlayer() from mtg-commander-lan.
Simulates simplified 1v1 Commander games:
N-player ready: accepts 2–4 decks and runs a full multiplayer game.
Simulates simplified Commander games:
- 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
"""
from __future__ import annotations
@@ -34,12 +37,15 @@ from commander_ai_lab.sim.rules import (
class GameEngine:
"""
Headless Commander game engine.
Headless Commander game engine (N-player ready).
Usage::
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"])
"""
def __init__(
@@ -65,15 +71,30 @@ class GameEngine:
name_a: str = "Player A",
name_b: str = "Player B",
) -> GameResult:
"""Run a single headless game and return the result."""
sim = self._create_state(deck_a, deck_b, name_a, name_b)
game_log: list[dict] = [] # turn-by-turn log
"""Backward-compatible 2-player entry point."""
return self.run_n(
decks=[deck_a, deck_b],
names=[name_a, name_b],
)
def run_n(
self,
decks: list[list[Card]],
names: list[str] | None = None,
) -> GameResult:
"""Run a single headless N-player game and return the result."""
n = len(decks)
if names is None:
names = [f"Player {chr(65 + i)}" for i in range(n)]
sim = self._create_state(decks, names)
game_log: list[dict] = []
elimination_order: list[int] = [] # seats in order of elimination
final_turn = 0
for turn in range(sim.max_turns):
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(2):
for pi in range(len(sim.players)):
p = sim.players[pi]
if p.eliminated:
continue
@@ -92,7 +113,6 @@ class GameEngine:
land_before = p.stats.lands_played
self._play_land(sim, pi)
if self.record_log and p.stats.lands_played > land_before:
# Find what land was just played
last_land = next(
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
@@ -105,7 +125,6 @@ class GameEngine:
available_mana = self._count_untapped_lands(sim, pi)
# ── Play spells (up to 2) ──
spells_before = p.stats.spells_cast
self._play_spells(sim, pi, available_mana, phase_events if self.record_log else None)
# ── Track board size ──
@@ -114,8 +133,6 @@ class GameEngine:
p.stats.max_board_size = board_size
# ── Combat ──
combat_before_life_a = sim.players[0].life
combat_before_life_b = sim.players[1].life
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
# ── Untap ──
@@ -123,15 +140,31 @@ class GameEngine:
c.tapped = False
if self.record_log:
turn_entry["phases"].append({
# 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 = {
"player": p.name,
"playerId": pi,
"events": phase_events,
"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()],
})
"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)
# ── 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]
@@ -139,15 +172,9 @@ class GameEngine:
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, name_a, name_b)
result = self._build_result(sim, final_turn, elimination_order)
if self.record_log:
result.game_log = game_log
return result
@@ -158,15 +185,13 @@ class GameEngine:
def _create_state(
self,
deck_a: list[Card],
deck_b: list[Card],
name_a: str,
name_b: str,
decks: list[list[Card]],
names: list[str],
) -> SimState:
"""Create initial simulation state with shuffled decks and opening hands."""
"""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([(deck_a, name_a), (deck_b, name_b)]):
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)
@@ -260,18 +285,19 @@ class GameEngine:
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal
# Handle removal — target the scariest creature across all opponents
if card.is_removal:
p.stats.removal_used += 1
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]
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:
@@ -317,16 +343,35 @@ class GameEngine:
# 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."""
"""Resolve combat phase for a player (N-player ready)."""
p = sim.players[pi]
opp_idx = 1 - 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
if opp.eliminated:
return
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
c
@@ -350,9 +395,6 @@ class GameEngine:
]
# ── Decide attackers ──
# Aggressive strategy: attack with most creatures.
# Only hold back small creatures (power < 3) when opponent has
# blockers that would profitably trade AND we have plenty of life.
attackers = []
total_my_power = sum(c.get_power() for c in my_creatures)
for atk in my_creatures:
@@ -362,29 +404,26 @@ class GameEngine:
has_trample = atk.has_keyword("trample")
has_haste = atk.has_keyword("haste")
# Always attack with evasion, big creatures, or if opponent is low
if has_flying or has_trample or has_haste or a_pow >= 3 or opp.life <= total_my_power:
attackers.append(atk)
atk.tapped = True
continue
# For small creatures, check if a profitable block exists
can_die_profitably = any(
(
(not has_flying or b.has_keyword("flying") or b.has_keyword("reach"))
and (b.get_power() >= a_tou or b.has_keyword("deathtouch"))
and b.get_toughness() > a_pow # blocker survives = bad trade
and b.get_toughness() > a_pow
)
for b in opp_blockers
)
# Attack unless we'd lose the creature for nothing AND we're healthy
if not can_die_profitably or p.life > 25:
attackers.append(atk)
atk.tapped = True
if events is not None and attackers:
atk_names = [f"{a.name} ({a.pt})" for a in attackers]
events.append(f"Attacks with: {', '.join(atk_names)}")
events.append(f"Attacks {opp.name} with: {', '.join(atk_names)}")
# ── Blocking and damage ──
total_damage = 0
@@ -407,12 +446,10 @@ class GameEngine:
blocked = False
if valid_blockers and not atk.has_keyword("menace"):
# Try to find a blocker that survives
blocker = next(
(b for b in valid_blockers if b.get_toughness() > a_pow),
None,
)
# If life is critical, chump block
if blocker is None and opp.life <= a_pow * 2 and valid_blockers:
blocker = valid_blockers[0]
@@ -424,25 +461,21 @@ class GameEngine:
if events is not None:
combat_details.append(f"{atk.name} blocked by {blocker.name}")
# Attacker kills blocker?
if a_pow >= blocker.get_toughness() or atk.has_keyword("deathtouch"):
sim.remove_from_battlefield(blocker.id)
sim.players[opp_idx].graveyard.append(blocker)
if events is not None:
combat_details.append(f" {blocker.name} dies")
# Blocker kills attacker?
if b_pow >= a_tou or blocker.has_keyword("deathtouch"):
sim.remove_from_battlefield(atk.id)
sim.players[pi].graveyard.append(atk)
if events is not None:
combat_details.append(f" {atk.name} dies")
# Trample overflow
if atk.has_keyword("trample") and a_pow > blocker.get_toughness():
total_damage += a_pow - blocker.get_toughness()
# Lifelink
if atk.has_keyword("lifelink"):
p.life += min(a_pow, blocker.get_toughness())
@@ -466,20 +499,46 @@ class GameEngine:
self,
sim: SimState,
final_turn: int,
name_a: str,
name_b: str,
elimination_order: list[int] | None = None,
) -> GameResult:
"""Determine winner and build result object."""
pa = sim.players[0]
pb = sim.players[1]
"""Determine winner and build result object (N-player).
if pa.eliminated and not pb.eliminated:
winner = 1
elif not pa.eliminated and pb.eliminated:
winner = 0
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)
else:
# Both alive or both dead: compare life
winner = 0 if pa.life >= pb.life else 1
# Timeout — alive player with highest life wins
winner = max(alive, key=lambda i: sim.players[i].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,
)
for seat in remaining_alive:
if seat not in finish:
finish[seat] = position
position += 1
player_results = []
for seat, p in enumerate(sim.players):
@@ -488,7 +547,7 @@ class GameEngine:
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=1 if seat == winner else 2,
finish_position=finish.get(seat, n),
stats=p.stats,
))
+191 -16
View File
@@ -4,16 +4,23 @@ 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)
@@ -67,6 +74,9 @@ 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
@@ -130,9 +140,12 @@ def enrich_card(card: Card) -> Card:
_apply_oracle_flags(card)
return card
# Default: treat as creature with random CMC
# 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).
if not card.type_line or len(card.type_line) < 3:
estimated_cmc = random.randint(2, 6)
name_hash = int(hashlib.md5(name_lower.encode()).hexdigest(), 16)
estimated_cmc = (name_hash % 5) + 2 # 2-6 inclusive
estimated_pow = max(1, estimated_cmc - 1)
card.type_line = "Creature"
card.cmc = estimated_cmc
@@ -181,15 +194,46 @@ def _apply_oracle_flags(card: Card) -> None:
card.is_ramp = True
def score_card(card: Card, weights: Optional[dict] = None) -> float:
# ── 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:
"""
Score a card for AI play priority.
Ported from headlessScoreCard() in app.js.
Ported from headlessScoreCard() in app.js. The optional
*num_opponents* parameter (default 1 for 1v1) scales keywords
that become more valuable in larger pods.
"""
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:
@@ -202,22 +246,83 @@ def score_card(card: Card, weights: Optional[dict] = None) -> float:
t = 0
score += (p + t) * w.get("card_ptBonus", 1.0)
# ── Keyword / oracle-text scoring ──
text = (card.oracle_text or "").lower()
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)
kw_list = [k.lower() for k in (card.keywords or [])]
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 += w.get("spell_destroy", 5.0)
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)
return score
@@ -242,3 +347,73 @@ 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)
View File
Whitespace-only changes.
+75
View File
@@ -0,0 +1,75 @@
"""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
View File
Whitespace-only changes.
+97
View File
@@ -0,0 +1,97 @@
"""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)
+214
View File
@@ -0,0 +1,214 @@
"""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
+189
View File
@@ -0,0 +1,189 @@
"""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"
+60 -311
View File
@@ -617,7 +617,7 @@
.lab-deck-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-3);
margin-bottom: var(--sp-5);
}
@@ -887,7 +887,7 @@
.lab-stats-cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-3);
margin-bottom: var(--sp-4);
}
@@ -972,6 +972,7 @@
.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 ───────────────────────────── */
@@ -1172,7 +1173,7 @@
.lab-combat-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, 1fr);
gap: var(--sp-4);
}
@@ -1456,6 +1457,16 @@
/* ── 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 {
@@ -1488,321 +1499,59 @@
width: 95%;
padding: var(--sp-4);
}
.lab-combat-grid {
grid-template-columns: 1fr;
}
.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;
}
}
/* ═══════════════════════════════════════════════════════════
Precon Decks — Grid + Cards
═══════════════════════════════════════════════════════════ */
/* ── Seat Color Overrides ──────────────────────────────── */
.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"] {
--seat-accent: var(--accent);
}
.lab-precon-btn:hover {
background: linear-gradient(135deg, var(--surface-2), var(--surface-3)) !important;
border-color: var(--error) !important;
}
.lab-precon-controls {
display: flex;
gap: var(--sp-3);
padding: 0 var(--sp-5) var(--sp-3);
align-items: center;
}
.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);
}
.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-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);
opacity: 0.8;
}
.lab-precon-card-installed .lab-precon-install-btn {
background: var(--success);
border-color: var(--success);
cursor: default;
}
.precon-card-colors {
display: flex;
gap: 4px;
margin-bottom: 2px;
}
.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 {
.lab-deck-slot[data-seat="0"].filled {
border-color: var(--accent);
color: var(--accent);
box-shadow: 0 0 8px var(--accent-glow);
}
.lab-deck-slot[data-seat="1"] {
--seat-accent: var(--error);
}
.lab-deck-slot[data-seat="1"].filled {
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-deck-slot[data-seat="2"].filled {
border-color: var(--success);
box-shadow: 0 0 8px var(--success-glow, rgba(63, 185, 80, 0.25));
}
.lab-deck-slot[data-seat="3"] {
--seat-accent: #d29922;
}
.lab-deck-slot[data-seat="3"].filled {
border-color: #d29922;
box-shadow: 0 0 8px rgba(210, 153, 34, 0.25);
}
/* 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; }
+1 -2531
View File
File diff suppressed because it is too large. Load diff
+137
View File
@@ -727,3 +727,140 @@ 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);
}