Files
commander-ai-lab/services/forge_runner.py
T
rgriffithandClaude Opus 4.6 9b79d18201 feat(lan-client): triggers panel + CMD DMG score badges + fix encoding project-wide
Left action panel revised to show triggers list + Create Token/Auto-Arrange.
CMD DMG badges added to center life-panel score areas (click opens matrix).
Removed renderEnhancedPanels calls that split the battlefield on damage adjust.
Added encoding='utf-8' to all bare open() calls across 17 Python files to
prevent cp1252 crashes on MTG card text with smart quotes/em-dashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 22:14:56 -04:00

750 lines
31 KiB
Python

"""Forge batch simulation runner: Java discovery, command building, process management."""
import asyncio
import json
import logging
import os
import re
import subprocess
import sys
import threading
import time
from datetime import datetime as _datetime
from pathlib import Path
from typing import Optional
from models.state import CFG, BatchState
from services.deck_service import parse_dck_file, _load_deck_cards_by_name
log = logging.getLogger("lab.batch")
log_sim = logging.getLogger("commander_ai_lab.sim")
# Global DeepSeek brain instance (lazy-initialized)
_deepseek_brain = None
_deepseek_lock = threading.Lock()
# ── Global watchdog: single thread monitors all active batches ──
_watchdog_registry: dict[str, dict] = {} # batch_id -> {state, proc, last_activity}
_watchdog_lock = threading.Lock()
_watchdog_started = False
_STALL_LIMIT = 600 # 10 min
def _global_watchdog_loop():
"""Single daemon thread that checks all active batches for stalls."""
while True:
time.sleep(5)
with _watchdog_lock:
finished = []
for bid, entry in _watchdog_registry.items():
proc = entry["proc"]
if proc.poll() is not None:
finished.append(bid)
continue
if (time.monotonic() - entry["last_activity"]) > _STALL_LIMIT:
log.warning(f"Stall detected for batch {bid}, killing process")
entry["state"].log_lines.append("[WATCHDOG] Process stalled. Killed.")
proc.kill()
entry["state"].running = False
entry["state"].error = "Stall detected (10 min no output)"
finished.append(bid)
for bid in finished:
del _watchdog_registry[bid]
def _ensure_watchdog_started():
global _watchdog_started
if not _watchdog_started:
with _watchdog_lock:
if not _watchdog_started:
t = threading.Thread(target=_global_watchdog_loop, daemon=True)
t.start()
_watchdog_started = True
def _register_batch(batch_id: str, state, proc):
_ensure_watchdog_started()
with _watchdog_lock:
_watchdog_registry[batch_id] = {
"state": state, "proc": proc, "last_activity": time.monotonic()
}
def _touch_batch(batch_id: str):
with _watchdog_lock:
if batch_id in _watchdog_registry:
_watchdog_registry[batch_id]["last_activity"] = time.monotonic()
def _unregister_batch(batch_id: str):
with _watchdog_lock:
_watchdog_registry.pop(batch_id, None)
# ── Java 17 Discovery ───────────────────────────────────────────────
# Ordered list of directories to scan for JDK 17 installations.
# Covers: Eclipse Temurin, Oracle, Amazon Corretto, Microsoft, Azul Zulu, SAP.
_JAVA17_SEARCH_DIRS = [
r'C:\Program Files\Eclipse Adoptium',
r'C:\Program Files\Java',
r'C:\Program Files\Amazon Corretto',
r'C:\Program Files\Microsoft',
r'C:\Program Files\Zulu',
r'C:\Program Files\BellSoft',
r'C:\Program Files\SapMachine',
]
def _find_java17() -> str:
"""
Search well-known JDK install directories for a Java 17 binary.
Falls back to JAVA17_HOME env var, then JAVA_HOME, then warns and returns 'java'.
Returns the absolute path to java.exe if found, otherwise 'java'.
"""
# 1. Explicit override via env var
env_override = os.environ.get('JAVA17_HOME', '').strip()
if env_override:
candidate = os.path.join(env_override, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found via JAVA17_HOME: {candidate}")
return candidate
log.warning(f"JAVA17_HOME set to '{env_override}' but java.exe not found there.")
# 2. Scan vendor directories
for d in _JAVA17_SEARCH_DIRS:
if not os.path.isdir(d):
continue
try:
children = os.listdir(d)
except OSError:
continue
for child in sorted(children): # sorted = deterministic, picks lowest 17.x
if re.match(r'jdk-?17', child, re.IGNORECASE) or re.match(r'temurin-17', child, re.IGNORECASE):
candidate = os.path.join(d, child, 'bin', 'java.exe')
if os.path.isfile(candidate):
log.info(f"Java 17 found: {candidate}")
return candidate
# 3. JAVA_HOME fallback — only accept if it IS version 17
java_home = os.environ.get('JAVA_HOME', '').strip()
if java_home:
candidate = os.path.join(java_home, 'bin', 'java.exe')
if os.path.isfile(candidate):
try:
result = subprocess.run(
[candidate, '-version'],
capture_output=True, text=True, timeout=5
)
version_output = result.stderr or result.stdout
if 'version "17' in version_output:
log.info(f"Java 17 found via JAVA_HOME: {candidate}")
return candidate
else:
log.warning(
f"JAVA_HOME points to '{java_home}' but it is not Java 17. "
f"Version output: {version_output.strip()!r}"
)
except Exception as e:
log.warning(f"Could not probe JAVA_HOME java version: {e}")
# 4. Nothing found — warn loudly so the log surfaces the issue
log.error(
"Java 17 not found! Forge simulation requires Java 17. "
"Install Eclipse Temurin 17 (winget install EclipseAdoptium.Temurin.17.JDK) "
"or set the JAVA17_HOME environment variable to your JDK 17 install directory. "
"Falling back to system 'java' which may be the wrong version."
)
return 'java'
_JAVA17_PATH = None
def get_java17() -> str:
global _JAVA17_PATH
if _JAVA17_PATH is None:
_JAVA17_PATH = _find_java17()
return _JAVA17_PATH
def reset_java17_cache():
"""Force re-discovery of Java 17 path (useful after installing JDK at runtime)."""
global _JAVA17_PATH
_JAVA17_PATH = None
# ── Deck path resolution ──────────────────────────────────────────────────────
def _resolve_deck_path(deck_name: str) -> str:
"""Resolve a deck name to its full .dck path in CFG.precon_dir.
Resolution order:
1. Already an absolute path with .dck extension — return as-is.
2. Exact filename match in precon_dir (e.g. "Abzan_Armor.dck").
3. camelCase → snake_case conversion (e.g. "AbzanArmor" → "Abzan_Armor.dck").
4. Space → underscore normalization.
5. Case-insensitive fuzzy match stripping underscores/spaces.
6. Fallback: return raw name and let the JAR fail with a clear error.
"""
# Already a resolved path
if deck_name.endswith('.dck') and os.path.isfile(deck_name):
return deck_name
precon_dir = CFG.precon_dir
if not precon_dir or not os.path.isdir(precon_dir):
log.warning(f"[DeckResolver] precon_dir not set or missing — passing raw name '{deck_name}' to JAR")
return deck_name
# 1. Exact match
exact = os.path.join(precon_dir, f"{deck_name}.dck")
if os.path.isfile(exact):
return exact
# 2. camelCase → snake_case (AbzanArmor → Abzan_Armor)
snake = re.sub(r'(?<=[a-z0-9])(?=[A-Z])', '_', deck_name)
snake_path = os.path.join(precon_dir, f"{snake}.dck")
if os.path.isfile(snake_path):
return snake_path
# 3. Space → underscore (e.g. "Abzan Armor" → "Abzan_Armor.dck")
spaced = deck_name.replace(' ', '_')
spaced_path = os.path.join(precon_dir, f"{spaced}.dck")
if os.path.isfile(spaced_path):
return spaced_path
# 4. Case-insensitive fuzzy: strip underscores/spaces, compare lowercased stems
target = deck_name.lower().replace('_', '').replace(' ', '')
try:
for fname in os.listdir(precon_dir):
if fname.lower().endswith('.dck'):
stem = fname[:-4].lower().replace('_', '').replace(' ', '')
if stem == target:
resolved = os.path.join(precon_dir, fname)
log.info(f"[DeckResolver] Fuzzy match: '{deck_name}' → '{fname}'")
return resolved
except OSError as e:
log.warning(f"[DeckResolver] Could not list precon_dir '{precon_dir}': {e}")
log.warning(f"[DeckResolver] Deck not found: '{deck_name}' (searched {precon_dir}) — passing raw name to JAR")
return deck_name
def build_java_command(
decks: list, num_games: int, threads: int, seed: Optional[int],
clock: int, output_path: str, use_learned_policy: bool = False,
policy_server: str = "http://localhost:8080", policy_style: str = "midrange",
policy_greedy: bool = False, ai_simplified: bool = False,
ai_think_time_ms: int = -1, max_queue_depth: int = -1,
) -> list:
java17 = get_java17()
lab_jar = CFG.lab_jar
forge_jar = CFG.forge_jar
forge_dir = CFG.forge_dir
if not lab_jar or not os.path.isfile(lab_jar):
log.error(f"Lab JAR not found at '{lab_jar}'. Run 'mvn package' first.")
raise FileNotFoundError(f"Lab JAR not found: {lab_jar}")
cmd = [
java17, '-jar', lab_jar,
'--forge-jar', forge_jar,
'--forge-dir', forge_dir,
'--games', str(num_games),
'--threads', str(threads),
'--clock', str(clock),
'--output', output_path,
]
# Map deck list to --deck1 .. --deck4, resolving names to full .dck paths
for i, d in enumerate(decks[:4], start=1):
resolved = _resolve_deck_path(str(d))
log.info(f"[DeckResolver] deck{i}: '{d}' → '{resolved}'")
cmd += [f'--deck{i}', resolved]
if seed is not None:
cmd += ['--seed', str(seed)]
if use_learned_policy:
cmd += ['--policy', policy_style, '--policyServer', policy_server]
if policy_greedy:
cmd += ['--policyGreedy']
if ai_simplified:
cmd += ['--aiSimplified']
if ai_think_time_ms > 0:
cmd += ['--aiThinkTimeMs', str(ai_think_time_ms)]
cmd += ['--ml-log']
if max_queue_depth > 0:
cmd += ['--maxQueueDepth', str(max_queue_depth)]
return cmd
# ── Draw-game life-at-timeout parser ─────────────────────────────────────────
# Forge emits a summary line for each game that hits the wall-clock timeout:
#
# [DRAW] [PARSE-SUMMARY] game=42 turns=28 time_ms=61200
# seat0=AdaptiveEnchantment:life=46 seat1=AbzanArmor:life=0
# seat2=AhoyMateys:life=0 seat3=20WaysToWin:life=8
#
# We capture these live so they're available before the process finishes.
_DRAW_SUMMARY_RE = re.compile(
r'\[DRAW\].*?\[PARSE-SUMMARY\]'
r'.*?game=(\d+)'
r'.*?turns=(\d+)'
r'.*?time_ms=(\d+)'
r'((?:\s+seat\d+=\S+)*)'
, re.IGNORECASE
)
_SEAT_LIFE_RE = re.compile(r'seat(\d+)=([^:]+):life=([-\d]+)', re.IGNORECASE)
def _parse_draw_summary_line(line: str) -> Optional[dict]:
"""Return a draw-game snapshot dict if line matches, else None."""
m = _DRAW_SUMMARY_RE.search(line)
if not m:
return None
seats = {}
for sm in _SEAT_LIFE_RE.finditer(m.group(4) or ""):
seat_idx = int(sm.group(1))
seats[seat_idx] = {"deck_name": sm.group(2).strip(), "life": int(sm.group(3))}
return {
"game_id": int(m.group(1)),
"turns": int(m.group(2)),
"time_ms": int(m.group(3)),
"seats": seats,
}
def _run_process_blocking(state: BatchState, cmd: list):
"""Run Forge subprocess, parse progress, collect draw-game snapshots."""
log.info(f"Running: {' '.join(cmd)}")
# Forge must run with forge_dir as cwd so it can find res/ relative to itself
forge_cwd = CFG.forge_dir if CFG.forge_dir and os.path.isdir(CFG.forge_dir) else None
log.info(f"[ForgeRunner] cwd={forge_cwd}")
# Ensure Forge subprocesses use Java 17
env = os.environ.copy()
java17 = get_java17()
if java17 != 'java':
java17_bin = os.path.dirname(java17)
java17_home = os.path.dirname(java17_bin)
env['JAVA_HOME'] = java17_home
env['PATH'] = java17_bin + os.pathsep + env.get('PATH', '')
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, bufsize=1, env=env, cwd=forge_cwd,
)
state.process = proc
except Exception as e:
state.running = False
state.error = str(e)
log.error(f"Failed to start subprocess: {e}")
return
_register_batch(state.batch_id, state, proc)
# ── Live stdout parsing ───────────────────────────────────────────
draw_snapshots: list = []
for line in proc.stdout:
_touch_batch(state.batch_id)
line = line.rstrip()
state.log_lines.append(line)
# Progress counters
gm = re.match(r'\[Game (\d+)/(\d+)\]', line)
if gm:
state.completed_games = int(gm.group(1))
state.total_games = int(gm.group(2))
pm = re.match(r'\[PROGRESS\].*?(\d+\.?\d*)\s*sims/sec', line)
if pm:
state.sims_per_sec = float(pm.group(1))
# ── Draw-game capture (fix #176) ──────────────────────────────
snap = _parse_draw_summary_line(line)
if snap:
draw_snapshots.append(snap)
proc.wait()
_unregister_batch(state.batch_id)
elapsed = (_datetime.now() - state.start_time).total_seconds() * 1000
state.elapsed_ms = int(elapsed)
# Persist draw snapshots on state for routes/lab.py to embed in JSON
state.draw_game_snapshots = draw_snapshots
if draw_snapshots:
log.info(
f"[DrawGames] Captured {len(draw_snapshots)} draw-game snapshots "
f"for batch {state.batch_id}"
)
if proc.returncode != 0:
state.running = False
tail = ' | '.join(state.log_lines[-5:]) if state.log_lines else '(no output)'
state.error = f"Exit code {proc.returncode} — last output: {tail}"
else:
state.running = False
state.completed_games = state.total_games
# ── Post-process: detect alternate win conditions in the Java log ──
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
state.win_type_override = detect_win_type_from_log(state.log_lines)
if state.win_type_override != "Combat":
log.info(f"[WinType] Alternate win detected in batch {state.batch_id}: {state.win_type_override}")
except Exception as _wcp_err:
log.debug(f"[WinType] Parser import failed (non-fatal): {_wcp_err}")
state.win_type_override = "Combat"
async def run_batch_subprocess(
state: BatchState,
decks: list,
num_games: int,
threads: int,
seed: Optional[int] = None,
clock: int = 3000,
output_path: str = "results",
use_learned_policy: bool = False,
policy_style: str = "midrange",
policy_greedy: bool = False,
ai_simplified: bool = False,
ai_think_time_ms: int = -1,
max_queue_depth: int = -1,
):
"""Async wrapper that runs Forge subprocess in executor.
clock default lowered from 6000 → 3000 (30s per-game wall limit for batch
mode) to reduce draw-game wall time wasted on timeouts (fix #176).
Interactive games pass an explicit req.clock override so they are unaffected.
"""
try:
policy_server = f"http://localhost:{CFG.port}"
cmd = build_java_command(
decks, num_games, threads, seed, clock, output_path,
use_learned_policy=use_learned_policy,
policy_server=policy_server,
policy_style=policy_style,
policy_greedy=policy_greedy,
ai_simplified=ai_simplified,
ai_think_time_ms=ai_think_time_ms,
max_queue_depth=max_queue_depth,
)
log.info(f"Starting batch {state.batch_id}: {' '.join(cmd[:6])}...")
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, _run_process_blocking, state, cmd)
except Exception as e:
state.error = str(e)
state.running = False
log.error(f"Batch {state.batch_id} ERROR: {e}")
def _get_deepseek_brain():
"""Get or create the global DeepSeek brain instance."""
global _deepseek_brain
if _deepseek_brain is None:
with _deepseek_lock:
if _deepseek_brain is None:
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.deepseek_brain import DeepSeekBrain, DeepSeekConfig
cfg = DeepSeekConfig(
api_base=os.environ.get("BRAIN_API_BASE", "http://localhost:11434"),
model=os.environ.get("BRAIN_MODEL", "qwen2.5:7b"),
temperature=float(os.environ.get("BRAIN_TEMPERATURE", "0.3")),
max_tokens=int(os.environ.get("BRAIN_MAX_TOKENS", "1024")),
request_timeout=float(os.environ.get("BRAIN_TIMEOUT", "300.0")),
)
cfg.log_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'logs', 'decisions')
cfg.log_dir = os.path.normpath(cfg.log_dir)
_deepseek_brain = DeepSeekBrain(cfg)
except Exception as e:
log_sim.error(f'Failed to initialize brain: {e}')
return None
return _deepseek_brain
def _run_deepseek_batch_thread(
state: BatchState,
deck_names: list,
num_games: int,
output_path: str,
):
"""Run batch simulation using Python sim engine + DeepSeek AI opponent."""
try:
src_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'src')
src_dir = os.path.normpath(src_dir)
if src_dir not in sys.path:
sys.path.insert(0, src_dir)
from commander_ai_lab.sim.models import Card
from commander_ai_lab.sim.deepseek_engine import DeepSeekGameEngine
from commander_ai_lab.sim.rules import enrich_card
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_cards
state.log_lines.append('[DeepSeek Batch] Initializing DeepSeek brain...')
brain = _get_deepseek_brain()
if brain and not brain._connected:
brain.check_connection()
if not brain or not brain._connected:
state.error = 'DeepSeek LLM not connected. Go to Simulator > DeepSeek and connect first.'
state.running = False
return
state.log_lines.append(f'[DeepSeek Batch] Connected to {brain.config.model}')
deck_win_rates = {}
try:
results_dir = CFG.results_dir
if os.path.isdir(results_dir):
for fname in sorted(os.listdir(results_dir), reverse=True):
if fname.startswith('ml-decision-') and fname.endswith('.json'):
try:
with open(os.path.join(results_dir, fname), 'r', encoding='utf-8') as rf:
past = json.loads(rf.read())
for dd in past.get('decks', []):
dname = dd.get('deckName', '')
wr = dd.get('winRate')
if dname and wr is not None and dname not in deck_win_rates:
deck_win_rates[dname] = float(wr)
except Exception:
pass
except Exception:
pass
loaded_decks = {}
deck_meta = {}
for dn in deck_names:
raw_cards = _load_deck_cards_by_name(dn)
if not raw_cards:
state.log_lines.append(f'[DeepSeek Batch] WARNING: Could not load deck "{dn}", skipping.')
continue
commander_name = ''
color_identity_set = set()
deck_objs = []
for cd in raw_cards:
c = Card(name=(cd.get('card_name') or cd.get('name', '')))
if cd.get('type_line'): c.type_line = cd['type_line']
if cd.get('cmc'): c.cmc = float(cd['cmc'])
if cd.get('power') and cd.get('toughness'):
c.power = str(cd['power'])
c.toughness = str(cd['toughness'])
c.pt = c.power + '/' + c.toughness
if cd.get('oracle_text'): c.oracle_text = cd['oracle_text']
if cd.get('mana_cost'): c.mana_cost = cd['mana_cost']
if cd.get('keywords'):
kw = cd['keywords']
if isinstance(kw, str):
try: kw = json.loads(kw)
except Exception: kw = []
if isinstance(kw, list): c.keywords = kw
if cd.get('is_commander'):
c.is_commander = True
commander_name = (cd.get('card_name') or cd.get('name', ''))
ci_str = cd.get('color_identity', '')
if ci_str:
try:
ci_parsed = json.loads(ci_str) if isinstance(ci_str, str) else ci_str
if isinstance(ci_parsed, list):
for color in ci_parsed:
color_identity_set.add(color)
except Exception:
pass
enrich_card(c)
deck_objs.append(c)
creature_count = sum(1 for c in deck_objs if c.is_creature())
removal_count = sum(1 for c in deck_objs if c.is_removal)
ramp_count = sum(1 for c in deck_objs if c.is_ramp)
avg_cmc = sum(c.cmc or 0 for c in deck_objs if not c.is_land()) / max(sum(1 for c in deck_objs if not c.is_land()), 1)
oracle_all = ' '.join((c.oracle_text or '').lower() for c in deck_objs)
has_combo_text = any(kw in oracle_all for kw in ['you win the game', 'infinite', 'extra turn'])
if has_combo_text:
archetype = 'combo'
elif creature_count >= 30 and avg_cmc <= 2.8:
archetype = 'aggro'
elif removal_count >= 10 or (creature_count <= 18 and avg_cmc >= 3.2):
archetype = 'control'
else:
archetype = 'midrange'
color_identity_list = sorted(list(color_identity_set))
deck_meta[dn] = {
'commander_name': commander_name,
'color_identity': color_identity_list,
'archetype': archetype,
'win_rate': deck_win_rates.get(dn),
}
loaded_decks[dn] = deck_objs
cmdr_info = f' (Commander: {commander_name})' if commander_name else ''
wr_info = f' [History: {deck_win_rates[dn]:.0f}% WR]' if dn in deck_win_rates else ''
state.log_lines.append(f'[DeepSeek Batch] Loaded deck "{dn}" ({len(deck_objs)} cards, {archetype}){cmdr_info}{wr_info}')
if not loaded_decks:
state.error = 'No decks could be loaded.'
state.running = False
return
deck_list = list(loaded_decks.keys())
games_per_deck = max(1, num_games // len(deck_list))
total_games = games_per_deck * len(deck_list)
state.total_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Running {games_per_deck} games per deck \u00d7 {len(deck_list)} decks = {total_games} total')
engine = DeepSeekGameEngine(
brain=brain,
ai_player_index=0,
max_turns=25,
record_log=True,
ml_log=True,
)
start_time = time.time()
all_deck_results = []
completed = 0
for deck_name in deck_list:
deck_a = loaded_decks[deck_name]
from commander_ai_lab.lab.experiments import _generate_training_deck
deck_stats = {
'deckName': deck_name,
'wins': 0, 'losses': 0, 'totalGames': games_per_deck,
'totalTurns': 0, 'totalDamageDealt': 0, 'totalDamageReceived': 0,
'totalSpellsCast': 0, 'totalCreaturesPlayed': 0,
'games': [],
}
meta = deck_meta.get(deck_name, {})
dk_archetype = meta.get('archetype', 'midrange')
dk_commander = meta.get('commander_name', '')
dk_colors = meta.get('color_identity', [])
dk_win_rate = meta.get('win_rate')
for g in range(games_per_deck):
try:
deck_b = _generate_training_deck()
game_id = f'ds-{state.batch_id}-{deck_name[:12]}-g{g+1}'
result = engine.run(
deck_a, deck_b,
name_a=deck_name + ' (AI)',
name_b='Training Opponent',
game_id=game_id, archetype=dk_archetype,
commander_name=dk_commander,
color_identity=dk_colors,
win_rate=dk_win_rate,
)
gd = result.to_dict()
gd['gameNumber'] = g + 1
# ── Alternate win detection (DeepSeek path) ──────────────
try:
winner_seat = result.winner_seat
if winner_seat >= 0:
winner_bf = getattr(result, '_winner_battlefield', None)
if winner_bf is not None:
win_type = detect_win_type_from_cards(winner_bf)
else:
from commander_ai_lab.sim.win_condition_parser import detect_win_type_from_log
win_type = detect_win_type_from_log(
[str(entry) for entry in (result.game_log or [])]
)
result.win_type = win_type
gd['winType'] = win_type
if win_type != 'Combat':
log.info(f'[WinType] {deck_name} game {g+1}: {win_type} win detected')
except Exception as _wt_err:
log.debug(f'[WinType] Detection failed for game {g+1} (non-fatal): {_wt_err}')
gd.setdefault('winType', 'Combat')
deck_stats['games'].append(gd)
if result.winner == 0:
deck_stats['wins'] += 1
else:
deck_stats['losses'] += 1
deck_stats['totalTurns'] += result.turns
if result.player_a_stats:
deck_stats['totalDamageDealt'] += result.player_a_stats.damage_dealt
deck_stats['totalDamageReceived'] += result.player_a_stats.damage_received
deck_stats['totalSpellsCast'] += result.player_a_stats.spells_cast
deck_stats['totalCreaturesPlayed'] += result.player_a_stats.creatures_played
state.log_lines.append(
f'[Game {completed + 1}/{total_games}] {deck_name} (AI-piloted) \u2192 '
f'{"WIN" if result.winner == 0 else "LOSS"} (turn {result.turns})'
)
except Exception as ge:
state.log_lines.append(f'[Game {completed + 1}/{total_games}] ERROR: {ge}')
deck_stats['games'].append({'error': str(ge), 'gameNumber': g + 1})
completed += 1
state.completed_games = completed
n = deck_stats['totalGames']
deck_stats['winRate'] = round(deck_stats['wins'] / n * 100, 1) if n > 0 else 0.0
deck_stats['avgTurns'] = round(deck_stats['totalTurns'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageDealt'] = round(deck_stats['totalDamageDealt'] / n, 1) if n > 0 else 0.0
deck_stats['avgDamageReceived'] = round(deck_stats['totalDamageReceived'] / n, 1) if n > 0 else 0.0
deck_stats['avgSpellsCast'] = round(deck_stats['totalSpellsCast'] / n, 1) if n > 0 else 0.0
deck_stats['avgCreaturesPlayed'] = round(deck_stats['totalCreaturesPlayed'] / n, 1) if n > 0 else 0.0
deck_stats['archetype'] = dk_archetype
deck_stats['commander'] = dk_commander
deck_stats['colorIdentity'] = dk_colors
if dk_win_rate is not None:
deck_stats['priorWinRate'] = dk_win_rate
all_deck_results.append(deck_stats)
elapsed = time.time() - start_time
ds_stats = brain.get_stats() if brain else {}
batch_result = {
'metadata': {
'batchId': state.batch_id,
'timestamp': _datetime.now().isoformat(),
'completedGames': completed,
'threads': 1,
'elapsedMs': int(elapsed * 1000),
'engine': 'deepseek',
'model': brain.config.model if brain else 'unknown',
},
'decks': all_deck_results,
'deepseekStats': ds_stats,
}
ml_decisions = engine.flush_ml_decisions()
if ml_decisions:
ml_jsonl_path = os.path.join(CFG.results_dir, f'ml-decisions-ds-{state.batch_id}.jsonl')
os.makedirs(os.path.dirname(ml_jsonl_path) or '.', exist_ok=True)
with open(ml_jsonl_path, 'w', encoding='utf-8') as mf:
for dec in ml_decisions:
mf.write(json.dumps(dec) + '\n')
state.log_lines.append(f'[ML Data] Wrote {len(ml_decisions)} decision snapshots to {os.path.basename(ml_jsonl_path)}')
else:
state.log_lines.append('[ML Data] No decision snapshots captured (0 decisions)')
os.makedirs(os.path.dirname(output_path), exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(batch_result, f, indent=2, default=str)
state.result_path = output_path
state.elapsed_ms = int(elapsed * 1000)
state.running = False
state.completed_games = total_games
state.log_lines.append(f'[DeepSeek Batch] Complete: {completed} games in {elapsed:.1f}s')
for ds in all_deck_results:
state.log_lines.append(f' {ds["deckName"]}: {ds["wins"]}W-{ds["losses"]}L ({ds["winRate"]}% WR)')
log_sim.info(f'Batch {state.batch_id} complete: {completed} games in {elapsed:.1f}s')
except Exception as e:
import traceback
state.error = str(e)
state.running = False
state.log_lines.append(f'[DeepSeek Batch] FATAL: {e}')
traceback.print_exc()