Files
commander-ai-lab/scripts/overnight-batch.py
T
rgriffith 5cb4bc3316 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
2026-03-22 02:31:05 +00:00

276 lines
9.4 KiB
Python

"""
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()