#!/usr/bin/env python3 """Run paired seeded games through the official local Battlesnake rules engine.""" from __future__ import annotations import argparse import json import os import re import signal import subprocess import sys import tempfile import time import urllib.error import urllib.request from collections import Counter from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from threading import Thread from pathlib import Path from statistics import mean ROOT = Path(__file__).resolve().parents[1] ENGINE_USER_AGENT = "BattlesnakeEngine/local-tournament" RESULT_RE = re.compile( r"Game completed after (\d+) turns\.(?: (.+?) was the winner\.| It was a draw\.)" ) def wait_for_server(url: str, process: subprocess.Popen, timeout: float = 15.0) -> None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if process.poll() is not None: raise RuntimeError(f"Snake server exited with status {process.returncode}") try: with urllib.request.urlopen(url, timeout=0.5) as response: if response.status == 200: return except OSError: time.sleep(0.1) raise TimeoutError(f"Snake server did not become ready at {url}") class _EngineHeaderProxy(BaseHTTPRequestHandler): target: str def do_GET(self) -> None: self._forward() def do_POST(self) -> None: self._forward() def _forward(self) -> None: length = int(self.headers.get("Content-Length", 0)) body = self.rfile.read(length) if length else None request = urllib.request.Request( f"{self.target}{self.path}", data=body, method=self.command, headers={ "Content-Type": self.headers.get("Content-Type", "application/json"), "User-Agent": ENGINE_USER_AGENT, }, ) try: with urllib.request.urlopen(request, timeout=2.0) as response: payload = response.read() self.send_response(response.status) self.send_header("Content-Type", response.headers.get("Content-Type", "application/json")) except urllib.error.HTTPError as error: payload = error.read() self.send_response(error.code) self.send_header("Content-Type", error.headers.get("Content-Type", "text/plain")) self.send_header("Content-Length", str(len(payload))) self.end_headers() self.wfile.write(payload) def log_message(self, format: str, *args) -> None: pass def start_proxy(port: int, target_port: int) -> tuple[ThreadingHTTPServer, Thread]: handler = type( f"EngineHeaderProxy{port}", (_EngineHeaderProxy,), {"target": f"http://127.0.0.1:{target_port}"}, ) server = ThreadingHTTPServer(("127.0.0.1", port), handler) thread = Thread(target=server.serve_forever, daemon=True) thread.start() return server, thread def start_server(snake: str, port: int) -> subprocess.Popen: env = os.environ.copy() env.update({ "HOST": "127.0.0.1", "PORT": str(port), "SNAKE": snake, "DEBUG": "false", "DEBUG_SERVER": "false", "STORE_GAME_HISTORY": "false", "GAMEPLAY_DB_ENABLED": "false", "METRICS_CLEAR_WORKERS_ON_STARTUP": "false", }) process = subprocess.Popen( [sys.executable, str(ROOT / "main.py")], cwd=ROOT, env=env, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, ) wait_for_server(f"http://127.0.0.1:{port}", process) return process def stop_server(process: subprocess.Popen) -> None: if process.poll() is not None: return os.killpg(process.pid, signal.SIGTERM) try: process.wait(timeout=5) except subprocess.TimeoutExpired: os.killpg(process.pid, signal.SIGKILL) process.wait(timeout=5) def run_game( cli: Path, seed: int, game_type: str, map_name: str, players: list[tuple[str, str]], width: int, height: int, timeout_ms: int, ) -> dict: with tempfile.NamedTemporaryFile(prefix="snake-arena-", suffix=".jsonl") as output: command = [ str(cli), "play", "-W", str(width), "-H", str(height), "-g", game_type, "--map", map_name, "--seed", str(seed), "--timeout", str(timeout_ms), "--output", output.name, ] for name, url in players: command.extend(("--name", name, "--url", url)) completed = subprocess.run( command, cwd=ROOT, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=180, check=False, ) if completed.returncode != 0: raise RuntimeError( f"Rules engine failed for seed {seed}:\n{completed.stdout[-2000:]}" ) output.seek(0) lines = [json.loads(line) for line in output if line.strip()] terminal = lines[-1] if lines else {} match = RESULT_RE.search(completed.stdout) turns = int(match.group(1)) if match else max(0, len(lines) - 2) winner = terminal.get("winnerName") or None draw = bool(terminal.get("isDraw", winner is None)) return {"seed": seed, "winner": winner, "draw": draw, "turns": turns} def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--games", type=int, default=20, help="Number of unique seeds") parser.add_argument("--seed-start", type=int, default=1) parser.add_argument("--gametype", default="standard") parser.add_argument("--map", default="standard") parser.add_argument("--width", type=int, default=11) parser.add_argument("--height", type=int, default=11) parser.add_argument("--timeout", type=int, default=500) parser.add_argument("--base-port", type=int, default=9301) parser.add_argument("--cli", default=str(ROOT / ".testing/tools/battlesnake-cli/battlesnake")) parser.add_argument("--json-output") args = parser.parse_args() cli = Path(args.cli) if not cli.is_file(): raise SystemExit(f"Battlesnake CLI not found: {cli}. Run: just build-battlesnake-cli") apex_port, prism_port = args.base_port, args.base_port + 1 apex_proxy_port, prism_proxy_port = args.base_port + 2, args.base_port + 3 servers: list[subprocess.Popen] = [] proxies: list[tuple[ThreadingHTTPServer, Thread]] = [] results: list[dict] = [] started = time.perf_counter() try: servers = [ start_server("ApexBattleSnake", apex_port), start_server("PrismBattleSnake_GPT_5_6_Sol", prism_port), ] proxies = [ start_proxy(apex_proxy_port, apex_port), start_proxy(prism_proxy_port, prism_port), ] urls = { "Apex": f"http://127.0.0.1:{apex_proxy_port}", "Prism": f"http://127.0.0.1:{prism_proxy_port}", } for offset in range(max(1, args.games)): seed = args.seed_start + offset # Swap engine slots for every seed. This controls for deterministic map # spawn positions and gives each strategy both initial placements. for order in (("Apex", "Prism"), ("Prism", "Apex")): result = run_game( cli=cli, seed=seed, game_type=args.gametype, map_name=args.map, players=[(name, urls[name]) for name in order], width=args.width, height=args.height, timeout_ms=args.timeout, ) result["order"] = list(order) results.append(result) print( f"seed={seed} order={'/'.join(order)} winner={result['winner'] or 'draw'} " f"turns={result['turns']}" ) finally: for proxy, thread in reversed(proxies): proxy.shutdown() proxy.server_close() thread.join(timeout=2) for server in reversed(servers): stop_server(server) wins = Counter(result["winner"] or "draw" for result in results) summary = { "games": len(results), "unique_seeds": max(1, args.games), "gametype": args.gametype, "map": args.map, "wins": dict(wins), "win_rates": { key: value / len(results) for key, value in wins.items() }, "mean_turns": mean(result["turns"] for result in results), "elapsed_seconds": time.perf_counter() - started, "results": results, } print(json.dumps({key: value for key, value in summary.items() if key != "results"}, indent=2)) if args.json_output: Path(args.json_output).write_text(json.dumps(summary, indent=2) + "\n") if __name__ == "__main__": main()