3a9af3f54d
- Split active strategies, reusable engine code, core classes, and legacy snakes. - Replace implicit snake imports with explicit module registrations. - Extract Prism duel, spatial, and survival behavior into focused mixins. - Improve duel scoring with food races, pressure, caches, and depth metrics. - Add deterministic arena scenarios and paired seeded engine tournaments. - Expand benchmark telemetry and bump Prism to version 1.3.0. - Update documentation and tests for the new package layout and tooling.
239 lines
7.9 KiB
Python
239 lines
7.9 KiB
Python
#!/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()
|