From 5cb4bc33163d191506b2e7b5c6beda0839c235f3 Mon Sep 17 00:00:00 2001 From: rgriffith <265133236+KoalaTrapLord@users.noreply.github.com> Date: Sun, 22 Mar 2026 02:31:05 +0000 Subject: [PATCH] feat: add overnight batch sim runner for ML data generation Script to run sequential batch sims across 10 precon decks with rotating playstyles (midrange, aggro, control, combo). Generates ML decision data for training. - 210 unique 4-player matchups from 10 precon decks - Cycles through playstyles each batch - Polls for completion, handles errors gracefully - Ensures ML logging is enabled before starting - Prints summary with new decision count at end Usage: python scripts/overnight-batch.py --hours 8 --- scripts/overnight-batch.py | 275 +++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 scripts/overnight-batch.py diff --git a/scripts/overnight-batch.py b/scripts/overnight-batch.py new file mode 100644 index 0000000..2932f33 --- /dev/null +++ b/scripts/overnight-batch.py @@ -0,0 +1,275 @@ +""" +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()