Merge pull request #77 from KoalaTrapLord/feat/overnight-batch-runner
feat: overnight batch sim runner for ML data generation
This commit is contained in:
1 file changed
+275
@@ -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()
|
||||
Reference in new issue
Block a user