""" Commander AI Lab — Overnight Batch Sim Runner ═══════════════════════════════════════════════ Runs sequential batch simulations across the top precon decks, rotating playstyles each batch. Generates ML decision data for training. Usage: python scripts/overnight-batch.py python scripts/overnight-batch.py --hours 8 --games-per-batch 200 python scripts/overnight-batch.py --port 8080 --threads 4 Estimated throughput (varies by hardware): ~3-5 sims/sec typical → ~200 games in ~40-70 sec 8 hours ≈ 400-600 batches ≈ 80K-120K games ≈ 200K-400K decisions """ import argparse import json import os import sys import time import urllib.request import urllib.error from datetime import datetime, timedelta from itertools import combinations # ── Configuration ──────────────────────────────────────────── PRECON_DECKS = [ "Undead_Unleashed.dck", # Wilhelt — Zombie tribal (UB) "Elven_Empire.dck", # Lathril — Elf tribal (BG) "Planar_Portal.dck", # Prosper — Exile/Treasure (BR) "Necron_Dynasties.dck", # Szarekh — Artifact recursion (B) "Creative_Energy.dck", # Satya — Energy/Artifacts (WUR) "Veloci-Ramp-Tor.dck", # Pantlaza — Dinosaur tribal (RGW) "Mutant_Menace.dck", # Wise Mothman — Rad/Proliferate (UBG) "Scrappy_Survivors.dck", # Dogmeat — Aura/Equipment (WRG) "Explorers_of_the_Deep.dck", # Hakbal — Merfolk/Explore (UG) "Science.dck", # Dr. Madison Li — Artifact synergies (WUR) ] PLAYSTYLES = ["midrange", "aggro", "control", "combo"] def api_call(base_url, path, method="GET", body=None): """Make an API call to the local server.""" url = f"{base_url}{path}" headers = {"Content-Type": "application/json"} if body else {} data = json.dumps(body).encode() if body else None req = urllib.request.Request(url, data=data, headers=headers, method=method) try: with urllib.request.urlopen(req, timeout=30) as resp: return json.loads(resp.read().decode()) except urllib.error.HTTPError as e: error_body = e.read().decode() if e.fp else str(e) print(f" API Error {e.code}: {error_body}") return None except Exception as e: print(f" Connection error: {e}") return None def ensure_ml_logging(base_url): """Make sure ML logging is enabled.""" result = api_call(base_url, "/api/ml/toggle?enable=true", method="POST") if result: print(f" ML logging: {'enabled' if result.get('ml_logging_enabled') else 'DISABLED'}") return result def start_batch(base_url, decks, num_games, playstyle, threads): """Start a batch simulation.""" body = { "decks": decks, "numGames": num_games, "threads": threads, "policyStyle": playstyle, "clock": 6000, } return api_call(base_url, "/api/lab/start", method="POST", body=body) def poll_batch(base_url, batch_id, poll_interval=5): """Poll batch status until complete. Returns final status.""" while True: result = api_call(base_url, f"/api/lab/status/{batch_id}") if result is None: time.sleep(poll_interval) continue completed = result.get("completedGames", 0) total = result.get("totalGames", 0) running = result.get("running", False) sps = result.get("simsPerSec", 0) error = result.get("error") if error: print(f" ERROR: {error}") return result if not running and completed >= total and total > 0: return result if not running and total == 0: # Still starting up time.sleep(poll_interval) continue pct = (completed / total * 100) if total > 0 else 0 speed = f" ({sps:.1f} sims/sec)" if sps > 0 else "" print(f" Progress: {completed}/{total} ({pct:.0f}%){speed}", end="\r") time.sleep(poll_interval) def generate_matchups(decks, pod_size=4): """Generate 4-player pods from deck list, cycling through all combos.""" combos = list(combinations(decks, pod_size)) return combos def main(): parser = argparse.ArgumentParser( description="Commander AI Lab — Overnight Batch Runner" ) parser.add_argument( "--hours", type=float, default=8, help="Maximum hours to run (default: 8)", ) parser.add_argument( "--games-per-batch", type=int, default=200, help="Games per batch (default: 200)", ) parser.add_argument( "--threads", type=int, default=4, help="Simulation threads (default: 4)", ) parser.add_argument( "--port", type=int, default=8080, help="Lab server port (default: 8080)", ) parser.add_argument( "--pod-size", type=int, default=4, choices=[2, 3, 4], help="Players per pod (default: 4)", ) args = parser.parse_args() base_url = f"http://localhost:{args.port}" print() print("=" * 60) print(" Commander AI Lab — Overnight Batch Runner") print("=" * 60) print(f" Duration: {args.hours} hours") print(f" Games/batch: {args.games_per_batch}") print(f" Threads: {args.threads}") print(f" Pod size: {args.pod_size}") print(f" Decks: {len(PRECON_DECKS)}") print(f" Playstyles: {', '.join(PLAYSTYLES)}") print(f" Server: {base_url}") print("=" * 60) print() # Check server is up print("Checking server connectivity...") status = api_call(base_url, "/api/ml/status") if status is None: print("ERROR: Cannot reach the lab server. Is it running?") print(f" Expected at: {base_url}") sys.exit(1) print(f" Server OK — {status.get('total_decisions', 0):,} existing decisions") # Enable ML logging print("Enabling ML logging...") ensure_ml_logging(base_url) # Generate matchups matchups = generate_matchups(PRECON_DECKS, args.pod_size) print(f" Generated {len(matchups)} unique {args.pod_size}-player matchups") print() # Calculate stop time stop_time = datetime.now() + timedelta(hours=args.hours) print(f" Will run until: {stop_time.strftime('%I:%M %p')}") print() # ── Main Loop ──────────────────────────────────────────── batch_count = 0 total_games = 0 total_decisions_start = status.get("total_decisions", 0) matchup_idx = 0 playstyle_idx = 0 try: while datetime.now() < stop_time: # Pick next matchup and playstyle matchup = matchups[matchup_idx % len(matchups)] playstyle = PLAYSTYLES[playstyle_idx % len(PLAYSTYLES)] deck_names = [os.path.splitext(d)[0].replace("_", " ") for d in matchup] batch_count += 1 remaining = stop_time - datetime.now() hrs_left = remaining.total_seconds() / 3600 print(f"─── Batch {batch_count} ({hrs_left:.1f}h remaining) ───") print(f" Playstyle: {playstyle}") print(f" Decks: {', '.join(deck_names)}") # Start batch result = start_batch( base_url, list(matchup), args.games_per_batch, playstyle, args.threads, ) if result is None or "batchId" not in result: print(" Failed to start batch — waiting 30s and retrying...") time.sleep(30) continue batch_id = result["batchId"] print(f" Batch ID: {batch_id}") # Poll until complete final = poll_batch(base_url, batch_id) print() # Clear progress line if final: games = final.get("completedGames", 0) elapsed_s = final.get("elapsedMs", 0) / 1000 total_games += games print(f" Done: {games} games in {elapsed_s:.0f}s") else: print(" Batch completed (no status)") # Rotate matchup_idx += 1 playstyle_idx += 1 # Brief pause between batches time.sleep(2) print() except KeyboardInterrupt: print("\n\nStopped by user (Ctrl+C)") # ── Summary ────────────────────────────────────────────── # Check final decision count final_status = api_call(base_url, "/api/ml/status") total_decisions_end = final_status.get("total_decisions", 0) if final_status else 0 new_decisions = total_decisions_end - total_decisions_start print() print("=" * 60) print(" OVERNIGHT BATCH COMPLETE") print("=" * 60) print(f" Batches run: {batch_count}") print(f" Total games: {total_games:,}") print(f" New decisions: {new_decisions:,}") print(f" Total decisions: {total_decisions_end:,}") print(f" Decision files: {final_status.get('total_files', '?')}") print("=" * 60) print() print(" Next step: Run Closed-Loop Distillation from the Distill tab") print(" to train on this expanded dataset.") print() if __name__ == "__main__": main()