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 2193 additions and 3474 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,
))
+147 -172
View File
@@ -4,13 +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.
Supports 2–4 player Commander pods (N-player).
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
- Multiplayer threat targeting: attacks and removal target highest-threat opponent
- Elimination order tracking for N-player finish positions
"""
from __future__ import annotations
@@ -35,19 +37,15 @@ from commander_ai_lab.sim.rules import (
class GameEngine:
"""
Headless Commander game engine — supports 2–4 player pods.
Headless Commander game engine (N-player ready).
2-player usage (backward-compatible)::
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")
N-player usage::
result = engine.run_pod(
decks=[deck_a, deck_b, deck_c, deck_d],
names=["Alice", "Bob", "Carol", "Dave"],
)
# 4-player
result = engine.run_n(decks=[d1, d2, d3, d4], names=["A","B","C","D"])
"""
def __init__(
@@ -73,30 +71,27 @@ class GameEngine:
name_a: str = "Player A",
name_b: str = "Player B",
) -> GameResult:
"""Run a 1v1 headless game. Backward-compatible wrapper around run_pod()."""
return self.run_pod(decks=[deck_a, deck_b], names=[name_a, name_b])
"""Backward-compatible 2-player entry point."""
return self.run_n(
decks=[deck_a, deck_b],
names=[name_a, name_b],
)
def run_pod(
def run_n(
self,
decks: list[list[Card]],
names: Optional[list[str]] = None,
names: list[str] | None = None,
) -> GameResult:
"""Run an N-player pod game (2–4 players) and return the result."""
if not decks or len(decks) < 2:
raise ValueError("run_pod requires at least 2 decks")
"""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(len(decks))]
if len(names) != len(decks):
raise ValueError("len(names) must equal len(decks)")
names = [f"Player {chr(65 + i)}" for i in range(n)]
sim = self._create_state(decks, names)
game_log: list[dict] = []
# Track elimination order: first eliminated = highest finish_position number
sim.elimination_order: list[int] = [] # seat indices in elimination order
elimination_order: list[int] = [] # seats in order of elimination
final_turn = 0
for turn in range(sim.max_turns):
sim.turn = turn
turn_entry = {"turn": turn + 1, "phases": []}
for pi in range(len(sim.players)):
@@ -108,7 +103,7 @@ class GameEngine:
# ── Draw ──
if p.library:
drawn = p.library[-1]
drawn = p.library[-1] # peek before pop
p.hand.append(p.library.pop())
p.stats.cards_drawn += 1
if self.record_log:
@@ -119,7 +114,8 @@ class GameEngine:
self._play_land(sim, pi)
if self.record_log and p.stats.lands_played > land_before:
last_land = next(
(c for c in reversed(sim.get_battlefield(pi)) if c.is_land()),
(c for c in reversed(sim.get_battlefield(pi))
if c.is_land()),
None,
)
if last_land:
@@ -139,31 +135,38 @@ class GameEngine:
# ── Combat ──
self._resolve_combat(sim, pi, turn, phase_events if self.record_log else None)
# ── Check for new eliminations after combat ──
for opp_idx, opp in enumerate(sim.players):
if not opp.eliminated and opp.life <= 0:
opp.eliminated = True
sim.elimination_order.append(opp_idx)
if self.record_log:
phase_events.append(f"{opp.name} eliminated!")
# ── Untap ──
for c in sim.get_battlefield(pi):
c.tapped = False
if self.record_log:
life_snapshot = {sim.players[s].name: sim.players[s].life
for s in range(len(sim.players))}
boards = {f"board_{s}": [c.name for c in sim.get_battlefield(s) if c.is_creature()]
for s in range(len(sim.players))}
turn_entry["phases"].append({
# 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": life_snapshot,
**boards,
})
"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]
final_turn = turn + 1
if self.record_log:
@@ -171,8 +174,7 @@ class GameEngine:
if len(alive) <= 1:
break
# Mark any remaining alive players as not-eliminated (they survived)
result = self._build_result(sim, final_turn)
result = self._build_result(sim, final_turn, elimination_order)
if self.record_log:
result.game_log = game_log
return result
@@ -186,10 +188,11 @@ class GameEngine:
decks: list[list[Card]],
names: list[str],
) -> SimState:
"""Create initial simulation state for N players."""
"""Create initial simulation state with shuffled decks and opening hands (N players)."""
sim = SimState(max_turns=self.max_turns)
for idx, (deck, name) in enumerate(zip(decks, names)):
# Enrich + deep copy cards
cards = [enrich_card(c.clone()) for c in deck]
random.shuffle(cards)
@@ -236,65 +239,12 @@ class GameEngine:
if not c.tapped and c.is_land()
)
def _pick_removal_target_seat(self, sim: SimState, pi: int) -> int:
"""Pick the best opponent seat to target with removal.
Returns the seat index of the alive opponent with the highest-value
creature on the battlefield. Falls back to any alive opponent.
"""
w = self.weights
best_seat = -1
best_score = -1
for opp_idx, opp in enumerate(sim.players):
if opp_idx == pi or opp.eliminated:
continue
top_creature = max(
(c for c in sim.get_battlefield(opp_idx) if c.is_creature()),
key=lambda c: score_card(c, w),
default=None,
)
if top_creature is not None:
s = score_card(top_creature, w)
if s > best_score:
best_score = s
best_seat = opp_idx
if best_seat == -1:
# No creatures anywhere — return first alive opponent
for opp_idx, opp in enumerate(sim.players):
if opp_idx != pi and not opp.eliminated:
return opp_idx
return best_seat
def _pick_attack_target_seat(self, sim: SimState, pi: int) -> int:
"""Pick the best opponent seat to attack.
Targets the alive opponent with the highest total board power
(i.e. biggest threat). If tied or no creatures, targets the
opponent with the lowest life (most vulnerable).
"""
alive_opps = [
(opp_idx, opp)
for opp_idx, opp in enumerate(sim.players)
if opp_idx != pi and not opp.eliminated
]
if not alive_opps:
return -1
# Score by: total opponent board power (threat) then lowest life (vulnerability)
def threat_key(entry):
opp_idx, opp = entry
board_power = sum(
c.get_power() for c in sim.get_battlefield(opp_idx) if c.is_creature()
)
return (board_power, -opp.life)
target_idx, _ = max(alive_opps, key=threat_key)
return target_idx
def _play_spells(self, sim: SimState, pi: int, available_mana: int, events: list | None = None) -> None:
"""Play up to 2 spells from hand, prioritized by AI score."""
p = sim.players[pi]
w = self.weights
# Score playable non-land cards
playable = []
for i, card in enumerate(p.hand):
if card.is_land():
@@ -335,24 +285,23 @@ class GameEngine:
p.stats.mana_spent += card.cmc or 0
p.stats.spells_cast += 1
# Handle removal — target the scariest creature across all opponents
if card.is_removal:
p.stats.removal_used += 1
# N-player: target highest-value opponent creature
target_seat = self._pick_removal_target_seat(sim, pi)
if target_seat != -1:
opp_creatures = [
c for c in sim.get_battlefield(target_seat) if c.is_creature()
]
if opp_creatures:
opp_creatures.sort(key=lambda c: -score_card(c, w))
killed = opp_creatures[0]
sim.remove_from_battlefield(killed.id)
sim.players[killed.owner_id].graveyard.append(killed)
if events is not None:
events.append(f"Cast {card.name} (removal) — destroyed {killed.name}")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
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}")
else:
if events is not None:
events.append(f"Cast {card.name} (removal, no targets)")
@@ -390,18 +339,38 @@ class GameEngine:
events.append(f"Cast {card.name} for {card.cmc} mana")
played += 1
# Recalculate available mana for next spell
available_mana = self._count_untapped_lands(sim, pi)
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
"""Resolve combat phase: attacker targets the highest-threat alive opponent."""
p = sim.players[pi]
w = self.weights
@staticmethod
def _select_attack_target(sim: SimState, pi: int) -> int:
"""Select the best opponent to attack.
# N-player: pick target opponent by threat level
opp_idx = self._pick_attack_target_seat(sim, pi)
Multiplayer heuristic: attack the weakest non-eliminated opponent
(lowest life total). Ties broken by highest seat index (arbitrary
but deterministic).
Returns the seat index of the chosen target, or -1 if no valid target.
"""
best_seat = -1
best_life = float('inf')
for si, sp in enumerate(sim.players):
if si == pi or sp.eliminated:
continue
if sp.life < best_life:
best_life = sp.life
best_seat = si
return best_seat
def _resolve_combat(self, sim: SimState, pi: int, turn: int, events: list | None = None) -> None:
"""Resolve combat phase for a player (N-player ready)."""
p = sim.players[pi]
opp_idx = self._select_attack_target(sim, pi)
if opp_idx == -1:
return
return # no valid targets
opp = sim.players[opp_idx]
w = self.weights
# My creatures that can attack (not tapped, not summoning sick)
my_creatures = [
@@ -419,7 +388,10 @@ class GameEngine:
opp_blockers = [
c
for c in sim.get_battlefield(opp_idx)
if c.is_creature() and not c.tapped
if (
c.is_creature()
and not c.tapped
)
]
# ── Decide attackers ──
@@ -520,64 +492,67 @@ class GameEngine:
events.append(f"Dealt {total_damage} combat damage to {opp.name} (now {opp.life} life)")
events.extend(combat_details)
def _build_result(self, sim: SimState, final_turn: int) -> GameResult:
"""Determine winner and assign finish positions for all N players."""
num_players = len(sim.players)
elimination_order = getattr(sim, "elimination_order", [])
if opp.life <= 0:
opp.eliminated = True
# Players still alive at end of game — determine winner by life total
alive = [p for p in sim.players if not p.eliminated]
def _build_result(
self,
sim: SimState,
final_turn: int,
elimination_order: list[int] | None = None,
) -> GameResult:
"""Determine winner and build result object (N-player).
if not alive:
# Edge case: everyone eliminated (e.g. simultaneous lethal)
winner_seat = -1
elif len(alive) == 1:
winner_seat = alive[0].owner_id
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:
# Multiple players survived max_turns — highest life wins
winner_seat = max(
(p.owner_id for p in alive),
key=lambda idx: sim.players[idx].life,
)
# Timeout — alive player with highest life wins
winner = max(alive, key=lambda i: sim.players[i].life)
# Build finish positions:
# 1st = winner, then survivors ordered by life (desc), then eliminated in reverse order
# (last eliminated = best finish among eliminated)
survivor_seats = sorted(
[p.owner_id for p in alive if p.owner_id != winner_seat],
key=lambda idx: -sim.players[idx].life,
)
# elimination_order[0] = first out = worst finish
# Finish positions: winner=1, survivors=2..k, eliminated reversed
finish: dict[int, int] = {winner_seat: 1}
pos = 2
for seat in survivor_seats:
finish[seat] = pos
pos += 1
# 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] = pos
pos += 1
# Any remaining (shouldn't happen, but safety)
for seat in range(num_players):
if seat not in finish:
finish[seat] = pos
pos += 1
finish[seat] = position
position += 1
player_results = [
PlayerResult(
player_results = []
for seat, p in enumerate(sim.players):
player_results.append(PlayerResult(
seat_index=seat,
name=sim.players[seat].name,
life=sim.players[seat].life,
eliminated=sim.players[seat].eliminated,
finish_position=finish.get(seat, num_players),
stats=sim.players[seat].stats,
)
for seat in range(num_players)
]
name=p.name,
life=p.life,
eliminated=p.eliminated,
finish_position=finish.get(seat, n),
stats=p.stats,
))
return GameResult(
winner_seat=winner_seat,
winner_seat=winner,
turns=min(final_turn, sim.max_turns),
players=player_results,
)
+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);
}