Files
snake-python/scripts/run_seeded_snake_tournament.py
T
daniel156161 5efab77248 feat!: adopt Python 3.15 and support BattlesnakeArena
- Require Python 3.15 in project metadata, runtime pin, and Docker stages.
- Use lazy imports for database backends and type-only dependencies.
- Defer benchmark dependencies until their code paths are used.
- Rely on deferred annotations and use Path.info for file discovery.
- Accept BattlesnakeArena alongside BattlesnakeEngine on game endpoints.
- Advance quart_common for Python 3.15 and multi-agent validation.
- Add pytest development dependencies and refresh the uv lockfile.
- Test lazy backend loading, import order, and fresh file discovery.
- Document the runtime requirements and locked uv workflow.

BREAKING CHANGE: Python 3.15 or newer is required; earlier runtimes cannot parse the lazy import syntax.
2026-11-03 10:02:40 +01:00

236 lines
7.9 KiB
Python

#!/usr/bin/env python3
"""Run paired seeded games through the official local Battlesnake rules engine."""
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()