Files
snake-python/scripts/run_seeded_snake_tournament.py
T
daniel156161 a2414f0b3d
Build and Push Docker Container / build-and-push (push) Successful in 2m18s
feat: add Zenith strategy and move latency tracing
- Register Zenith 1.1 with balanced duel verdicts and forced-outcome overrides.
- Prioritize permanent-trail space in Constrictor and health-aware hazard routes.
- Deduct executor queue delays and return fallback directions on move timeouts.
- Correlate response headers, replay timings, and sampled structured trace logs.
- Move shared metrics publication off the response path without losing local counts.
- Allow configurable tournament opponents and add a psql helper recipe.
- Preserve empty latency values through compact replay encoding and migration.
- Update Arena game links and use default assets for missing customizations.
- Cover strategy, HTTP lifecycle, tracing, replay codecs, and dashboard behavior.
- Validate with 260 passing Python tests, 4 skips, and 2 passing JavaScript tests.
2026-11-05 01:11:31 +01:00

243 lines
8.4 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 display_name(snake: str) -> str:
"""Short engine display name, e.g. PrismBattleSnake_GPT_5_6_Sol -> Prism."""
return snake.split("_", 1)[0].removesuffix("BattleSnake") or snake
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("--snake-a", default="ApexBattleSnake", help="Registry name of the first snake")
parser.add_argument("--snake-b", default="PrismBattleSnake_GPT_5_6_Sol", help="Registry name of the second snake")
parser.add_argument("--json-output")
args = parser.parse_args()
if args.snake_a == args.snake_b:
raise SystemExit("--snake-a and --snake-b must differ")
cli = Path(args.cli)
if not cli.is_file():
raise SystemExit(f"Battlesnake CLI not found: {cli}. Run: just build-battlesnake-cli")
snakes = (args.snake_a, args.snake_b)
servers: list[subprocess.Popen] = []
proxies: list[tuple[ThreadingHTTPServer, Thread]] = []
results: list[dict] = []
started = time.perf_counter()
try:
urls: dict[str, str] = {}
labels = [display_name(snake) for snake in snakes]
if len(set(labels)) != len(labels):
labels = list(snakes)
for index, (snake, label) in enumerate(zip(snakes, labels)):
server_port = args.base_port + index
proxy_port = args.base_port + len(snakes) + index
servers.append(start_server(snake, server_port))
proxies.append(start_proxy(proxy_port, server_port))
urls[label] = f"http://127.0.0.1:{proxy_port}"
names = tuple(urls)
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 (names, names[::-1]):
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,
"snakes": list(snakes),
"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()