feat(snake): modularize engine and add tournament tools

- 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.
This commit is contained in:
2026-08-01 20:25:07 +02:00
parent cb6c8d4dc8
commit 3a9af3f54d
39 changed files with 1447 additions and 768 deletions
+31 -7
View File
@@ -48,7 +48,7 @@ battlesnake play -W 11 -H 11 --name 'Python Starter Project' --url http://localh
Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior. Continue with the [Battlesnake Quickstart Guide](https://docs.battlesnake.com/quickstart) to customize and improve your Battlesnake's behavior.
## Included Competitive Snake ## Included Competitive Snake
This repo now includes `snakes/BestBattleSnake.py`, a stronger default snake that combines: This repo retains `snakes/legacy/BestBattleSnake.py`, a stronger historical snake that combines:
- collision and head-to-head risk checks - collision and head-to-head risk checks
- flood-fill space evaluation to avoid traps - flood-fill space evaluation to avoid traps
- food routing that gets more aggressive as health drops - food routing that gets more aggressive as health drops
@@ -66,12 +66,23 @@ BATTLE_SNAKE_DUEL_STYLE=balanced python main.py
Allowed values: `safe`, `balanced`, `aggressive`. Allowed values: `safe`, `balanced`, `aggressive`.
## Snake package layout
The snake code is split by responsibility:
- `snakes/strategies/` — actively maintained Apex and Prism entry points
- `snakes/engine/` — reusable bitboards, spatial mixins, duel search, and survival search
- `snakes/core/` — shared base classes
- `snakes/legacy/` — historical snakes retained for compatibility and benchmarks
Snake selection still uses the existing registry names, so deployment values such
as `SNAKE=PrismBattleSnake_GPT_5_6_Sol` remain unchanged.
## PrismBattleSnake_GPT_5_6_Sol ## PrismBattleSnake_GPT_5_6_Sol
`PrismBattleSnake_GPT_5_6_Sol` is a separate snake that keeps Apex's strategy while `PrismBattleSnake_GPT_5_6_Sol` is a separate snake that keeps Apex's strategy while
accelerating hot spatial operations with a Python-integer bitboard engine. It accelerating hot spatial operations with a Python-integer bitboard engine. It
also shares duel transpositions across candidate moves, uses principal-variation also shares duel transpositions across candidate moves, uses principal-variation
ordering and aspiration windows, and runs a compact adversarial multiplayer ordering, aspiration windows, path-aware food races, and a deeper tactical
rollout with simultaneous enemy responses. Its filename, class, and registry horizon, and runs a compact adversarial multiplayer rollout with simultaneous
enemy responses. Its filename, class, and registry
key include the model name, while its public Battlesnake API name remains key include the model name, while its public Battlesnake API name remains
`PrismBattleSnake`. `PrismBattleSnake`.
@@ -95,11 +106,24 @@ Run the deterministic CI-friendly arena benchmark without a gameplay database:
just bench-snake-arena positions=100 just bench-snake-arena positions=100
``` ```
It reports latency, reached minimax depth, and move disagreements between Apex It rotates through duel, hazard, multiplayer, constrictor, and cramped-endgame
and Prism. Add `output=data/arena-report.json` to save a machine-readable report. positions. It reports latency, completed duel/rollout depth, searched nodes,
cache hits, deadline exits, and move disagreements between Apex and Prism. Use
`--scenario hazard` (repeatable) when invoking the Python script to isolate a
scenario. Add `output=data/arena-report.json` to save a machine-readable report.
For representative strategy evaluation, provide recorded positions to For representative strategy evaluation, provide recorded positions to
`scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`, then run `scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`.
paired seeded games with the local Battlesnake CLI to measure win rate.
Run paired seeded games through the official local Battlesnake rules engine:
```sh
just bench-snake-tournament games=20 gametype=standard map=standard
```
Each seed is played twice with Apex and Prism swapping initial engine slots. The
report includes wins, draws, win rates, and average game length. Save all
per-game results with `output=data/tournament-report.json`. The tournament starts
both snake servers with gameplay persistence disabled, adds the engine identity
header required by the API, and shuts them down when finished.
## Compact gameplay database ## Compact gameplay database
New gameplay turns use normalized storage: the turn row stores food, hazards, New gameplay turns use normalized storage: the turn row stores food, hazards,
+8
View File
@@ -70,6 +70,14 @@ bench-snake-arena positions="100" output="":
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}" PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/scripts/benchmark_snake_arena.py" "${args[@]}"
bench-snake-tournament games="20" gametype="standard" map="standard" output="":
#!/usr/bin/env bash
set -euo pipefail
args=(--games "{{games}}" --gametype "{{gametype}}" --map "{{map}}")
if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi
PYTHONPATH="{{justfile_directory()}}" python "{{justfile_directory()}}/scripts/run_seeded_snake_tournament.py" "${args[@]}"
build-battlesnake-cli: build-battlesnake-cli:
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
+53 -28
View File
@@ -17,34 +17,20 @@ from time import perf_counter
sys.path.insert(0, str(Path(__file__).resolve().parents[1])) sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from scripts.benchmark_snakes_from_db import load_states, percentile from scripts.benchmark_snakes_from_db import load_states, percentile
from scripts.snake_arena_scenarios import SCENARIOS, synthetic_states
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
from snakes import SnakeBuilder from snakes import SnakeBuilder
from tests.bench_best_battle_snake import build_game_state
def synthetic_states(count: int) -> list[tuple[dict, dict]]:
states: list[tuple[dict, dict]] = []
for index in range(count):
payload = build_game_state()
payload["game"]["id"] = f"arena-{index}"
payload["turn"] = 20 + index
# Rotate food deterministically to exercise routing without creating
# invalid bodies or relying on private/real network values.
payload["board"]["food"] = [
{"x": 1 + index % 3, "y": 9},
{"x": 9, "y": 1 + (index // 3) % 3},
]
states.append((payload["board"], {
"game_id": payload["game"]["id"],
"source": "custom", "map": "standard",
"ruleset": payload["game"]["ruleset"], "turn": payload["turn"],
"you": payload["you"],
}))
return states
def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dict]: def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dict]:
moves: list[str] = [] moves: list[str] = []
durations: list[float] = [] durations: list[float] = []
depths: list[int] = [] duel_depths: list[int] = []
rollout_depths: list[int] = []
duel_nodes = 0
rollout_nodes = 0
cache_hits = 0
deadline_exits = 0
scenario_durations: dict[str, list[float]] = {}
for index, (board_data, metadata) in enumerate(states): for index, (board_data, metadata) in enumerate(states):
snake = SnakeBuilder.build(name) snake = SnakeBuilder.build(name)
game_id = f"arena-{name}-{index}-{metadata['game_id']}" game_id = f"arena-{name}-{index}-{metadata['game_id']}"
@@ -62,15 +48,32 @@ def evaluate(name: str, states: list[tuple[dict, dict]]) -> tuple[list[str], dic
}) })
started = perf_counter() started = perf_counter()
moves.append(snake.choose_move(board)) moves.append(snake.choose_move(board))
durations.append((perf_counter() - started) * 1000.0) duration = (perf_counter() - started) * 1000.0
durations.append(duration)
scenario = metadata.get("scenario", "recorded")
scenario_durations.setdefault(scenario, []).append(duration)
history = snake.get_history() if hasattr(snake, "get_history") else [] history = snake.get_history() if hasattr(snake, "get_history") else []
if history: if history:
depths.append(int(history[-1].get("minimax_depth_reached", 0))) thinking = history[-1]
duel_depths.append(int(thinking.get("prism_duel_depth", thinking.get("minimax_depth_reached", 0))))
rollout_depths.append(int(thinking.get("prism_rollout_depth", 0)))
duel_nodes += int(thinking.get("prism_duel_nodes", 0))
rollout_nodes += int(thinking.get("prism_rollout_nodes", 0))
cache_hits += int(thinking.get("prism_duel_cache_hits", 0))
cache_hits += int(thinking.get("prism_rollout_cache_hits", 0))
deadline_exits += int(thinking.get("prism_duel_deadline_exits", 0))
deadline_exits += int(thinking.get("prism_rollout_deadline_exits", 0))
return moves, { return moves, {
"snake": name, "positions": len(states), "snake": name, "positions": len(states),
"mean_ms": mean(durations), "median_ms": median(durations), "mean_ms": mean(durations), "median_ms": median(durations),
"p95_ms": percentile(durations, 0.95), "max_ms": max(durations), "p95_ms": percentile(durations, 0.95), "max_ms": max(durations),
"mean_minimax_depth": mean(depths) if depths else 0.0, "mean_duel_depth": mean(duel_depths) if duel_depths else 0.0,
"mean_rollout_depth": mean(rollout_depths) if rollout_depths else 0.0,
"duel_nodes": duel_nodes, "rollout_nodes": rollout_nodes,
"cache_hits": cache_hits, "deadline_exits": deadline_exits,
"scenario_mean_ms": {
scenario: mean(values) for scenario, values in scenario_durations.items()
},
} }
def main() -> None: def main() -> None:
@@ -79,12 +82,13 @@ def main() -> None:
parser.add_argument("--database") parser.add_argument("--database")
parser.add_argument("--positions", type=int, default=100) parser.add_argument("--positions", type=int, default=100)
parser.add_argument("--stride", type=int, default=997) parser.add_argument("--stride", type=int, default=997)
parser.add_argument("--scenario", action="append", choices=sorted(SCENARIOS))
parser.add_argument("--json-output") parser.add_argument("--json-output")
args = parser.parse_args() args = parser.parse_args()
states = ( states = (
load_states(args.database, max(1, args.positions), max(1, args.stride)) load_states(args.database, max(1, args.positions), max(1, args.stride))
if args.database else synthetic_states(max(1, args.positions)) if args.database else synthetic_states(max(1, args.positions), args.scenario)
) )
if not states: if not states:
raise SystemExit("No benchmark positions found") raise SystemExit("No benchmark positions found")
@@ -99,7 +103,10 @@ def main() -> None:
print( print(
f"{name}: mean={report['mean_ms']:.3f} ms, " f"{name}: mean={report['mean_ms']:.3f} ms, "
f"p95={report['p95_ms']:.3f} ms, max={report['max_ms']:.3f} ms, " f"p95={report['p95_ms']:.3f} ms, max={report['max_ms']:.3f} ms, "
f"depth={report['mean_minimax_depth']:.2f}" f"duel-depth={report['mean_duel_depth']:.2f}, "
f"rollout-depth={report['mean_rollout_depth']:.2f}, "
f"nodes={report['duel_nodes'] + report['rollout_nodes']}, "
f"cache-hits={report['cache_hits']}, deadline-exits={report['deadline_exits']}"
) )
baseline = names[0] baseline = names[0]
disagreements = { disagreements = {
@@ -109,7 +116,25 @@ def main() -> None:
if disagreements: if disagreements:
print(f"Move disagreements versus {baseline}: {disagreements}") print(f"Move disagreements versus {baseline}: {disagreements}")
payload = {"reports": reports, "baseline": baseline, "disagreements": disagreements} scenario_disagreements = {}
for name in names[1:]:
counts: dict[str, int] = {}
for index, (baseline_move, candidate_move) in enumerate(
zip(move_sets[baseline], move_sets[name])
):
if baseline_move != candidate_move:
scenario = states[index][1].get("scenario", "recorded")
counts[scenario] = counts.get(scenario, 0) + 1
scenario_disagreements[name] = counts
if any(scenario_disagreements.values()):
print(f"Disagreements by scenario: {scenario_disagreements}")
payload = {
"reports": reports,
"baseline": baseline,
"disagreements": disagreements,
"scenario_disagreements": scenario_disagreements,
}
if args.json_output: if args.json_output:
Path(args.json_output).write_text(json.dumps(payload, indent=2) + "\n") Path(args.json_output).write_text(json.dumps(payload, indent=2) + "\n")
+238
View File
@@ -0,0 +1,238 @@
#!/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()
+108
View File
@@ -0,0 +1,108 @@
"""Deterministic scenario corpus for the local snake arena."""
from __future__ import annotations
from copy import deepcopy
from tests.bench_best_battle_snake import build_game_state
def _state(payload: dict, scenario: str, index: int) -> tuple[dict, dict]:
payload["game"]["id"] = f"arena-{scenario}-{index}"
return payload["board"], {
"game_id": payload["game"]["id"],
"source": "custom",
"map": payload["game"].get("map", "standard"),
"ruleset": payload["game"]["ruleset"],
"turn": payload["turn"],
"you": payload["you"],
"scenario": scenario,
}
def _standard_duel(index: int) -> dict:
payload = build_game_state()
payload["turn"] = 20 + index
payload["board"]["food"] = [
{"x": 1 + index % 3, "y": 9},
{"x": 9, "y": 1 + (index // 3) % 3},
]
return payload
def _hazard_duel(index: int) -> dict:
payload = _standard_duel(index)
payload["you"]["health"] = 38 + index % 12
payload["board"]["snakes"][0]["health"] = payload["you"]["health"]
hazard_x = 5 + index % 2
payload["board"]["hazards"] = [
{"x": hazard_x, "y": y} for y in range(1, 10) if y != 5
]
return payload
def _multiplayer(index: int) -> dict:
payload = _standard_duel(index)
third = {
"id": "enemy-2",
"name": "enemy-2",
"health": 65,
"length": 5,
"head": {"x": 2, "y": 8},
"body": [
{"x": 2, "y": 8}, {"x": 2, "y": 9}, {"x": 2, "y": 10},
{"x": 1, "y": 10}, {"x": 0, "y": 10},
],
}
payload["board"]["snakes"].append(third)
return payload
def _constrictor(index: int) -> dict:
payload = _multiplayer(index)
payload["game"]["ruleset"] = deepcopy(payload["game"]["ruleset"])
payload["game"]["ruleset"]["name"] = "constrictor"
payload["board"]["food"] = []
return payload
def _cramped_duel(index: int) -> dict:
payload = _standard_duel(index)
payload["board"]["width"] = 7
payload["board"]["height"] = 7
mine = {
"id": "me", "name": "me", "health": 72, "length": 7,
"head": {"x": 2, "y": 3},
"body": [
{"x": 2, "y": 3}, {"x": 2, "y": 2}, {"x": 2, "y": 1},
{"x": 1, "y": 1}, {"x": 1, "y": 2}, {"x": 1, "y": 3},
{"x": 1, "y": 4},
],
}
enemy = {
"id": "enemy", "name": "enemy", "health": 72, "length": 7,
"head": {"x": 4, "y": 3},
"body": [
{"x": 4, "y": 3}, {"x": 4, "y": 2}, {"x": 4, "y": 1},
{"x": 5, "y": 1}, {"x": 5, "y": 2}, {"x": 5, "y": 3},
{"x": 5, "y": 4},
],
}
payload["you"] = mine
payload["board"]["snakes"] = [mine, enemy]
payload["board"]["food"] = [{"x": 3, "y": 5 + index % 2}]
payload["board"]["hazards"] = []
return payload
SCENARIOS = {
"duel": _standard_duel,
"hazard": _hazard_duel,
"multi": _multiplayer,
"constrictor": _constrictor,
"cramped": _cramped_duel,
}
def synthetic_states(count: int, scenarios: list[str] | None = None) -> list[tuple[dict, dict]]:
selected = scenarios or list(SCENARIOS)
unknown = set(selected) - set(SCENARIOS)
if unknown:
raise ValueError(f"Unknown arena scenarios: {', '.join(sorted(unknown))}")
states: list[tuple[dict, dict]] = []
for index in range(count):
scenario = selected[index % len(selected)]
states.append(_state(SCENARIOS[scenario](index), scenario, index))
return states
+1 -1
View File
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
from datetime import datetime from datetime import datetime
class GameBoard: class GameBoard:
-663
View File
@@ -1,663 +0,0 @@
"""PrismBattleSnake_GPT_5_6_Sol v1.1.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
articulation detection, distance maps, pathfinding) replaced by a
bitboard engine that uses integer arithmetic instead of Python sets/deques.
Key speedups:
S1: Bitboard flood fill — replaces BFS deque+set with integer bit-expansion.
~60× faster per call, eliminates _neighbors() generator overhead.
S2: Bitboard territory — dual-BFS expansion on ints replaces per-cell
distance-map comparison loop.
S3: Bitboard articulation — partition sizes via bit-flood instead of
_bounded_bfs with sets.
S4: Bitboard distance map — BFS via bit-expansion + bit-extract.
S5: Bitboard path distance — early-exit BFS on ints.
S6: Bitboard nearest food — BFS food search on ints.
S7: Per-turn BitBoard instance cached for board dimensions.
S8: Blocked-set → bitboard conversion cached within a turn to avoid
redundant O(n) conversions for the same frozen set.
S9: Survival-tree uses bitboards natively — enemy body/attack bits
precomputed once at tree root, no per-node set/dict rebuilds.
S10: _legal_moves override uses bitboard neighbour mask instead of
per-direction Python loop + _in_bounds calls.
S11: _future_survival_tree inlines legal-move check with bitboard ops.
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
S14: Candidate duel moves and enemy replies resolve on the same root turn.
S15: Candidate moves share one duel transposition/search context per turn.
S16: Compact adversarial multiplayer rollout advances plausible enemy replies.
S17: Rollout memoization and adaptive depth spend time on ambiguous positions.
"""
from __future__ import annotations
from time import perf_counter
from server.GameBoard import GameBoard
from snakes.ApexBattleSnake import ApexBattleSnake
from snakes.bitboard import BitBoard
from snakes.bitboard_duel_search import BitboardDuelSearch
from snakes.compact_survival_search import CompactSurvivalSearch
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class PrismBattleSnake_GPT_5_6_Sol(ApexBattleSnake):
VERSION = "1.2.0"
def __init__(self) -> None:
super().__init__()
self.name = "PrismBattleSnake"
self.version = self.VERSION
# S7: cached BitBoard instance (reused while board dimensions stay the same)
self._bb: BitBoard | None = None
self._bb_w: int = 0
self._bb_h: int = 0
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# Shared per-turn search contexts. Candidate moves overlap heavily, so
# rebuilding their transposition tables wastes most iterative-deepening work.
self._duel_search_context: BitboardDuelSearch | None = None
self._survival_search_context: CompactSurvivalSearch | None = None
# ── BitBoard accessor ────────────────────────────────────────────────────
def _get_bb(self, width: int, height: int) -> BitBoard:
"""Return (possibly cached) BitBoard for the current dimensions."""
if self._bb is None or width != self._bb_w or height != self._bb_h:
self._bb = BitBoard(width, height)
self._bb_w = width
self._bb_h = height
return self._bb
def _blocked_to_bits(self, blocked: set[tuple[int, int]], width: int, height: int) -> int:
"""Convert blocked cells to bits without stale identity-based caching."""
return self._get_bb(width, height).set_to_bits(blocked)
# ── choose_move override: precompute enemy bits ──────────────────────────
def choose_move(self, game_data: GameBoard) -> str:
bb = self._get_bb(game_data.get_width(), game_data.get_height())
self._duel_search_context = None
self._survival_search_context = None
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
my_snake = game_data.get_my_snake()
my_len = my_snake.get("length", len(my_snake["body"]))
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
all_occupied = {
(seg["x"], seg["y"])
for snake in [my_snake, *other_snakes]
for seg in snake["body"]
}
game_type = game_data.get_type()
is_constrictor = game_type == "constrictor"
w = bb.width
enemy_body_bits = 0
enemy_tail_bits = 0
enemy_attack_danger = 0
enemy_attack_opportunity = 0
for snake in other_snakes:
for seg in snake["body"]:
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
body = snake["body"]
# Check if tail will vacate
if not is_constrictor and len(body) >= 2:
tail_stacked = (body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"])
if not tail_stacked:
can_grow = self._enemy_can_grow_this_turn(snake, food_set, all_occupied)
if not can_grow:
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
# Attack map: tiles enemy head can reach in 1 move
eh = snake["head"]
e_len = snake.get("length", len(body))
ehx, ehy = eh["x"], eh["y"]
for dx, dy in _DIR_DELTAS:
nx, ny = ehx + dx, ehy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
bit = 1 << (ny * w + nx)
if e_len >= my_len:
enemy_attack_danger |= bit
else:
enemy_attack_opportunity |= bit
self._enemy_body_bits = enemy_body_bits
self._enemy_tail_bits = enemy_tail_bits
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
move = super().choose_move(game_data)
history = self.get_history()
if history:
thinking = history[-1]
if self._duel_search_context is not None:
thinking["prism_duel_nodes"] = self._duel_search_context.nodes
thinking["prism_duel_cache_hits"] = self._duel_search_context.cache_hits
if self._survival_search_context is not None:
thinking["prism_rollout_nodes"] = self._survival_search_context.nodes
thinking["prism_rollout_cache_hits"] = self._survival_search_context.cache_hits
return move
# ── S1: Bitboard flood fill ──────────────────────────────────────────────
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
# A7/E2: per-turn transposition cache (kept from Apex)
cache_key = (start_idx, blocked_bits, width, height)
cached = self._bfs_cache.get(cache_key)
if cached is not None:
return cached
result = bb.flood_count(start_idx, blocked_bits)
if len(self._bfs_cache) < self._bfs_cache_max:
self._bfs_cache[cache_key] = result
return result
# ── S2: Bitboard territory ──────────────────────────────────────────────
def _territory_fast(
self, my_pos: tuple, blocked: set, width: int, height: int,
deadline: float | None = None,
) -> int:
if not self._enemy_heads:
return 0
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
my_idx = bb.idx(my_pos[0], my_pos[1])
enemy_idxs = [bb.idx(eh[0], eh[1]) for eh in self._enemy_heads]
return bb.territory(my_idx, enemy_idxs, blocked_bits)
# ── S3: Bitboard articulation penalty ────────────────────────────────────
def _articulation_penalty(
self, point: tuple, blocked: set, width: int, height: int, required_space: int,
) -> float:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
point_idx = bb.idx(point[0], point[1])
sizes = bb.partition_sizes(point_idx, blocked_bits)
if not sizes:
return 0.0
min_size = min(sizes)
if min_size < required_space:
return 1500.0
elif min_size < required_space * 2:
return 400.0
else:
return 85.0
def _bounded_bfs(self, start: tuple, blocked: set, width: int, height: int, limit: int) -> set:
"""Bitboard-accelerated bounded BFS. Returns a set for API compatibility."""
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
reachable_bits = bb.flood_fill(start_idx, blocked_bits)
result: set[tuple[int, int]] = set()
temp = reachable_bits
w = bb.width
while temp:
bit = temp & (-temp)
idx = bit.bit_length() - 1
result.add((idx % w, idx // w))
temp ^= bit
if len(result) >= limit:
break
return result
# ── S4: Bitboard distance map ───────────────────────────────────────────
def _distance_map(self, start: tuple, blocked: set, width: int, height: int) -> dict:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
idx_dmap = bb.distance_map(start_idx, blocked_bits)
w = bb.width
return {(idx % w, idx // w): d for idx, d in idx_dmap.items()}
# ── S5: Bitboard path distance ──────────────────────────────────────────
def _path_distance(
self, start: tuple, goal: tuple, blocked: set, width: int, height: int,
) -> int | None:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.path_distance(
bb.idx(start[0], start[1]),
bb.idx(goal[0], goal[1]),
blocked_bits,
)
# ── S6: Bitboard nearest food ───────────────────────────────────────────
def _nearest_food_info(
self, start: tuple, food_set: set, blocked: set, width: int, height: int,
) -> tuple[int | None, tuple | None]:
if not food_set:
return None, None
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
food_bits = bb.set_to_bits(food_set)
start_idx = bb.idx(start[0], start[1])
dist, cell_idx = bb.nearest_food(start_idx, food_bits, blocked_bits)
if dist is None or cell_idx is None:
return None, None
return dist, bb.coord(cell_idx)
# ── Bitboard open-neighbour helpers ──────────────────────────────────────
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(start[0], start[1]), blocked_bits)
def _next_turn_options(self, head: dict, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
# ── S12/S13: compact bitboard duel search ───────────────────────────────
def _new_duel_search(
self, food_set: set, hazard_set: set, hazard_count: dict,
hazard_damage: int, width: int, height: int, deadline: float | None,
) -> BitboardDuelSearch:
if self._duel_search_context is None:
self._duel_search_context = BitboardDuelSearch(
board=self._get_bb(width, height),
food=food_set,
hazards=hazard_set,
hazard_count=hazard_count,
hazard_damage=hazard_damage,
deadline=deadline,
)
return self._duel_search_context
def _minimax_candidate_id(
self, my_body: list, enemy_body: list, my_target: tuple[int, int],
food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Resolve our selected move and every enemy reply simultaneously."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
adaptive_depth = max_depth
remaining = self._remaining_ms(deadline)
if remaining > 250:
adaptive_depth = min(7, max_depth + 1)
elif remaining < 120:
adaptive_depth = min(max_depth, 2)
return search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=my_target,
my_health=my_health,
enemy_health=enemy_health,
max_depth=adaptive_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim_id(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Run iterative deepening with one reusable compact search context."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
max_depth=max_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> float:
"""Compatibility entry point for tests and callers requesting one depth."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search_depth(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
depth=depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
# ── S16/S17: compact adversarial survival rollout ───────────────────────
def _future_rollout_bonus(
self, move: str, safe_moves: dict, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict, deadline: float | None,
) -> float:
pos = safe_moves.get(move)
if pos is None:
return -250.0
# Duel minimax already advances the opponent exactly. Keep the much faster
# bitboard-native solo rollout here instead of paying for the same response
# model twice. Constrictor and multiplayer still use adversarial rollouts.
if len(other_snakes) == 1 and not is_constrictor:
return super()._future_rollout_bonus(
move, safe_moves, my_body, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
if self._survival_search_context is None:
remaining = self._remaining_ms(deadline)
enemy_branch = 2 if len(other_snakes) <= 2 and remaining > 100 else 1
self._survival_search_context = CompactSurvivalSearch(
board=self._get_bb(width, height),
food=food_set,
is_constrictor=is_constrictor,
deadline=deadline,
branch=self._planning_branch,
enemy_branch=enemy_branch,
response_cap=8 if remaining > 150 else 4,
)
remaining = self._remaining_ms(deadline)
depth = min(self._planning_depth, 2 if len(other_snakes) > 1 else 3)
if remaining < 90:
depth = min(depth, 2)
elif remaining > 250 and len(other_snakes) <= 2:
depth = min(4, depth + 1)
raw = self._survival_search_context.search_selected(
my_body=my_body,
enemies=other_snakes,
target=(pos["x"], pos["y"]),
depth=depth,
)
return raw * 0.15
# ── S9: Optimised survival tree (compatibility fallback) ────────────────
def _future_position_score(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict, deadline: float | None,
) -> float:
"""S9: Bitboard-native position scoring for the survival tree.
Builds blocked bitboard directly from body lists (no intermediate set).
Uses precomputed enemy bits instead of rebuilding attack map per node.
"""
if deadline is not None and perf_counter() >= deadline:
return 0.0
bb = self._bb # already initialised in choose_move
w = bb.width
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
head_bit = 1 << head_idx
body_len = len(my_body)
# ── Build blocked bitboard directly (no set) ──────────────────────
my_bits = 0
for seg in my_body:
my_bits |= 1 << (seg["y"] * w + seg["x"])
# Own tail vacates unless stacked or constrictor
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
my_bits &= ~(1 << (t["y"] * w + t["x"]))
# Enemy body (precomputed) minus vacating tails
en_bits = self._enemy_body_bits & ~self._enemy_tail_bits
blocked_bits = (my_bits | en_bits) & ~head_bit
# ── Reachable space ───────────────────────────────────────────────
reachable = bb.flood_count(head_idx, blocked_bits)
required = body_len + max(3, body_len // 6) if is_constrictor else body_len
if reachable < required:
return -5000.0
# ── Open neighbours (liberties) ───────────────────────────────────
nb_free = bb._neighbor_masks[head_idx] & ~blocked_bits & bb.board_mask
liberties = nb_free.bit_count()
if liberties == 0:
return -5000.0
# ── Safe next options (enemy-attack aware) ────────────────────────
# Rebuild danger for the simulated length. The root-turn danger mask is
# stale after eating and includes enemy moves blocked in this future body.
danger_here = 0
for enemy in other_snakes:
enemy_len = enemy.get("length", len(enemy["body"]))
if enemy_len < body_len:
continue
enemy_head = enemy["head"]
enemy_idx = enemy_head["y"] * w + enemy_head["x"]
danger_here |= bb._neighbor_masks[enemy_idx]
danger_here &= ~blocked_bits
safe_nb = nb_free & ~danger_here
en_safe = safe_nb.bit_count()
if en_safe == 0:
return -4000.0
next_opts = liberties
sc = reachable * 1.9 + liberties * 14.0 + next_opts * 11.0 + en_safe * 26.0
if en_safe == 1:
sc -= 420.0
return sc
def _future_survival_tree(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict,
depth: int, branch: int, deadline: float | None,
) -> float:
"""S9/S11: Bitboard-accelerated survival tree.
Inlines legal-move check with bitboard ops instead of per-direction
Python loops. Uses the bitboard-native _future_position_score.
"""
if depth <= 0 or (deadline is not None and perf_counter() >= deadline):
return 0.0
bb = self._bb
w = bb.width
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
body_len = len(my_body)
# ── Build occupied bitboard for legal-move check ──────────────────
occupied_bits = 0
for seg in my_body:
occupied_bits |= 1 << (seg["y"] * w + seg["x"])
occupied_bits |= self._enemy_body_bits
# Own tail can be stepped on if not stacked/constrictor
passable = 0
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy vacating tails are also steppable
passable |= self._enemy_tail_bits
# Legal moves: free neighbours OR passable tiles
legal_bits = bb._neighbor_masks[head_idx] & ((~occupied_bits & bb.board_mask) | passable)
if not legal_bits:
return -5000.0
# ── Precompute food bitboard once ─────────────────────────────────
food_bits_local = 0
for fx, fy in food_set:
food_bits_local |= 1 << (fy * w + fx)
# ── Score each legal move ─────────────────────────────────────────
scored: list[tuple[float, list]] = []
temp = legal_bits
while temp:
if deadline is not None and perf_counter() >= deadline:
break
bit = temp & (-temp)
temp ^= bit
idx = bit.bit_length() - 1
nx, ny = idx % w, idx // w
pos = {"x": nx, "y": ny}
ate = bool(bit & food_bits_local)
fb = self._future_body(my_body, pos, ate, is_constrictor)
sc = self._future_position_score(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
scored.append((sc, fb))
if not scored:
return -5000.0
DEATH = self._TREE_DEATH_THRESHOLD
viable = [(sc, fb) for sc, fb in scored if sc > DEATH]
if not viable:
return max(sc for sc, _ in scored)
viable.sort(key=lambda x: x[0], reverse=True)
if depth == 1:
return viable[0][0]
best = viable[0][0]
for sc, fb in viable[:branch]:
if deadline is not None and perf_counter() >= deadline:
break
cont = self._future_survival_tree(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, depth - 1, branch, deadline,
)
total = sc + cont * 0.72
if total > best:
best = total
return best
# ── S10: Bitboard legal moves ────────────────────────────────────────────
def _legal_moves(
self, my_head, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict | None = None,
):
"""S10: Bitboard-accelerated legal move generation."""
bb = self._get_bb(width, height)
w = bb.width
# Build occupied bitboard
occupied = 0
for seg in my_body:
occupied |= 1 << (seg["y"] * w + seg["x"])
for snake in other_snakes:
for seg in snake["body"]:
occupied |= 1 << (seg["y"] * w + seg["x"])
hx, hy = my_head["x"], my_head["y"]
head_idx = hy * w + hx
# Own tail can be stepped on
passable = 0
if not is_constrictor and len(my_body) >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy tails that will vacate
if not is_constrictor:
for snake in other_snakes:
sbody = snake["body"]
if len(sbody) < 2:
continue
st, st2 = sbody[-1], sbody[-2]
if st["x"] == st2["x"] and st["y"] == st2["y"]:
continue # stacked
sid = snake.get("id")
can_grow = None
if enemy_can_grow is not None and sid is not None:
can_grow = enemy_can_grow.get(sid)
if can_grow is None:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
passable |= 1 << (st["y"] * w + st["x"])
legal = bb._neighbor_masks[head_idx] & ((~occupied & bb.board_mask) | passable)
safe: dict[str, dict[str, int]] = {}
for name, (dx, dy) in self.DIRECTIONS.items():
nx, ny = hx + dx, hy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
if (1 << (ny * w + nx)) & legal:
safe[name] = {"x": nx, "y": ny}
return safe
# ── Enemy confinement (uses bitboard flood) ──────────────────────────────
def _enemy_confinement_metrics(
self, enemy_head: tuple, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
eh_idx = bb.idx(enemy_head[0], enemy_head[1])
eb_bits = blocked_bits & ~(1 << eh_idx)
space = bb.flood_count(eh_idx, eb_bits)
options = bb.open_neighbor_count(eh_idx, eb_bits)
return space, options
def _enemy_constrictor_projection(
self, other_snakes: list, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
best_space = 0
total_opts = 0
for enemy in other_snakes:
eh = (enemy["head"]["x"], enemy["head"]["y"])
eh_idx = bb.idx(eh[0], eh[1])
nb = bb.neighbors_of(eh_idx) & ~blocked_bits & bb.board_mask
temp = nb
while temp:
total_opts += 1
bit = temp & (-temp)
n_idx = bit.bit_length() - 1
sp = bb.flood_count(n_idx, blocked_bits | bit)
if sp > best_space:
best_space = sp
temp ^= bit
return best_space, total_opts
+46 -25
View File
@@ -1,40 +1,61 @@
import importlib import importlib
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class SnakeRegistration:
module: str
version: str
SNAKE_REGISTRATIONS = {
"TemplateSnake": SnakeRegistration("snakes.core.template", "1.0.0"),
"ApexBattleSnake": SnakeRegistration("snakes.strategies.apex", "1.0.0"),
"PrismBattleSnake_GPT_5_6_Sol": SnakeRegistration(
"snakes.strategies.prism", "1.3.0"
),
"DummSnake": SnakeRegistration("snakes.legacy.DummSnake", "1.0.0"),
"LogicSnake": SnakeRegistration("snakes.legacy.LogicSnake", "1.1.0"),
"MasterSnake": SnakeRegistration("snakes.legacy.MasterSnake", "1.2.0"),
"BetterMasterSnake": SnakeRegistration("snakes.legacy.BetterMasterSnake", "1.3.0"),
"BestBattleSnake": SnakeRegistration("snakes.legacy.BestBattleSnake", "2.6.0"),
"TrainedBattleSnake": SnakeRegistration(
"snakes.legacy.TrainedBattleSnake", "0.1.0"
),
"UltimateBattleSnake": SnakeRegistration(
"snakes.legacy.UltimateBattleSnake", "4.5.0"
),
"SupremeBattleSnake_ClaudeOpus4_6": SnakeRegistration(
"snakes.legacy.SupremeBattleSnake_ClaudeOpus4_6",
"1.0.0",
),
}
# Backward-compatible public version map.
SNAKE_REGISTRY = { SNAKE_REGISTRY = {
"TemplateSnake": "1.0.0", name: registration.version for name, registration in SNAKE_REGISTRATIONS.items()
"DummSnake": "1.0.0",
"LogicSnake": "1.1.0",
"MasterSnake": "1.2.0",
"BetterMasterSnake": "1.3.0",
"BestBattleSnake": "2.6.0",
"TrainedBattleSnake": "0.1.0",
"UltimateBattleSnake": "4.5.0",
"ApexBattleSnake": "1.0.0",
"SupremeBattleSnake_ClaudeOpus4_6": "1.0.0",
"PrismBattleSnake_GPT_5_6_Sol": "1.2.0",
} }
DEFAULT_SNAKE_CONFIG = { DEFAULT_SNAKE_CONFIG = {
'apiversion': '1', "apiversion": "1",
'author': '', "author": "",
'color': '#888888', "color": "#888888",
'head': 'default', "head": "default",
'tail': 'default', "tail": "default",
} }
def build_snake(selected_snake:str):
if selected_snake not in SNAKE_REGISTRY: def build_snake(selected_snake: str):
registration = SNAKE_REGISTRATIONS.get(selected_snake)
if registration is None:
raise ValueError(f"Unknown snake: {selected_snake}") raise ValueError(f"Unknown snake: {selected_snake}")
snake_module = importlib.import_module(f"snakes.{selected_snake}") snake_module = importlib.import_module(registration.module)
snake_class = getattr(snake_module, selected_snake) snake_class = getattr(snake_module, selected_snake)
return snake_class() return snake_class()
def get_snake_version(selected_snake:str) -> str|None: def get_snake_version(selected_snake: str) -> str | None:
version = SNAKE_REGISTRY.get(selected_snake) registration = SNAKE_REGISTRATIONS.get(selected_snake)
if version is None: return registration.version if registration is not None else None
return None
return str(version)
class SnakeBuilder: class SnakeBuilder:
@classmethod @classmethod
@@ -42,5 +63,5 @@ class SnakeBuilder:
return build_snake(selected_snake) return build_snake(selected_snake)
@classmethod @classmethod
def get_version(self, selected_snake:str) -> str|None: def get_version(self, selected_snake: str) -> str | None:
return get_snake_version(selected_snake) return get_snake_version(selected_snake)
+5
View File
@@ -0,0 +1,5 @@
"""Shared snake base classes."""
from snakes.core.template import TemplateSnake
__all__ = ("TemplateSnake",)
+18
View File
@@ -0,0 +1,18 @@
"""Reusable high-performance board and search engines for competitive snakes."""
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel import BitboardDuelMixin
from snakes.engine.duel_search import BitboardDuelSearch, DuelState
from snakes.engine.spatial import BitboardSpatialMixin
from snakes.engine.survival import BitboardSurvivalMixin
from snakes.engine.survival_search import CompactSurvivalSearch
__all__ = (
"BitBoard",
"BitboardDuelMixin",
"BitboardDuelSearch",
"BitboardSpatialMixin",
"BitboardSurvivalMixin",
"CompactSurvivalSearch",
"DuelState",
)
+89
View File
@@ -0,0 +1,89 @@
"""Reusable compact duel-search integration for Apex-style snakes."""
from __future__ import annotations
from snakes.engine.duel_search import BitboardDuelSearch
class BitboardDuelMixin:
def _new_duel_search(
self, food_set: set, hazard_set: set, hazard_count: dict,
hazard_damage: int, width: int, height: int, deadline: float | None,
) -> BitboardDuelSearch:
if self._duel_search_context is None:
self._duel_search_context = BitboardDuelSearch(
board=self._get_bb(width, height),
food=food_set,
hazards=hazard_set,
hazard_count=hazard_count,
hazard_damage=hazard_damage,
deadline=deadline,
)
return self._duel_search_context
def _minimax_candidate_id(
self, my_body: list, enemy_body: list, my_target: tuple[int, int],
food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Resolve our selected move and every enemy reply simultaneously."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
adaptive_depth = max_depth
remaining = self._remaining_ms(deadline)
if remaining > 250:
adaptive_depth = min(7, max_depth + 1)
elif remaining < 120:
adaptive_depth = min(max_depth, 2)
return search.search_candidate(
my_body=my_body,
enemy_body=enemy_body,
my_target=my_target,
my_health=my_health,
enemy_health=enemy_health,
max_depth=adaptive_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim_id(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, max_depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> tuple[float, int]:
"""Run iterative deepening with one reusable compact search context."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
max_depth=max_depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
def _minimax_sim(
self, my_body: list, enemy_body: list, food_set: set, hazard_set: set,
my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict,
width: int, height: int, depth: int, alpha: float, beta: float,
deadline: float | None, previous_hazard_set: set | None = None,
) -> float:
"""Compatibility entry point for tests and callers requesting one depth."""
search = self._new_duel_search(
food_set, hazard_set, hazard_count, hazard_damage,
width, height, deadline,
)
return search.search_depth(
my_body=my_body,
enemy_body=enemy_body,
my_health=my_health,
enemy_health=enemy_health,
depth=depth,
previous_hazards=previous_hazard_set if previous_hazard_set is not None else hazard_set,
)
@@ -6,7 +6,7 @@ from collections.abc import Iterable
from dataclasses import dataclass from dataclasses import dataclass
from time import perf_counter from time import perf_counter
from snakes.bitboard import BitBoard from snakes.engine.bitboard import BitBoard
Body = tuple[int, ...] Body = tuple[int, ...]
@@ -51,8 +51,12 @@ class BitboardDuelSearch:
self.killer_moves: dict[int, int] = {} self.killer_moves: dict[int, int] = {}
self.history: dict[int, int] = {} self.history: dict[int, int] = {}
self._body_bits_cache: dict[Body, int] = {} self._body_bits_cache: dict[Body, int] = {}
self._evaluation_cache: dict[DuelState, float] = {}
self.nodes = 0 self.nodes = 0
self.cache_hits = 0 self.cache_hits = 0
self.evaluation_cache_hits = 0
self.completed_depth = 0
self.deadline_exits = 0
def body_from_dicts(self, body: list[dict]) -> Body: def body_from_dicts(self, body: list[dict]) -> Body:
return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body) return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body)
@@ -90,6 +94,7 @@ class BitboardDuelSearch:
result = value result = value
completed_depth = depth completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth return result, completed_depth
def search_candidate( def search_candidate(
@@ -135,6 +140,7 @@ class BitboardDuelSearch:
break break
result = value result = value
completed_depth = depth completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth return result, completed_depth
def search_depth( def search_depth(
@@ -154,7 +160,9 @@ class BitboardDuelSearch:
enemy_health=enemy_health, enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)), previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
) )
value, _ = self._search(state, depth, -float("inf"), float("inf")) value, completed = self._search(state, depth, -float("inf"), float("inf"))
if completed:
self.completed_depth = max(self.completed_depth, depth)
return value return value
def _search_selected_move( def _search_selected_move(
@@ -344,6 +352,11 @@ class BitboardDuelSearch:
return sorted(moves, key=score, reverse=True) return sorted(moves, key=score, reverse=True)
def _evaluate(self, state: DuelState) -> float: def _evaluate(self, state: DuelState) -> float:
cached = self._evaluation_cache.get(state)
if cached is not None:
self.evaluation_cache_hits += 1
return cached
my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:]) my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:])
my_head, enemy_head = state.my_body[0], state.enemy_body[0] my_head, enemy_head = state.my_body[0], state.enemy_body[0]
my_space = self.board.flood_count(my_head, my_blocked) my_space = self.board.flood_count(my_head, my_blocked)
@@ -356,14 +369,48 @@ class BitboardDuelSearch:
tail_score = (12.0 if my_tail_path is not None else -24.0) - (12.0 if enemy_tail_path is not None else -24.0) tail_score = (12.0 if my_tail_path is not None else -24.0) - (12.0 if enemy_tail_path is not None else -24.0)
my_hazard = self._hazard_cost(my_head, state.previous_hazard_bits) my_hazard = self._hazard_cost(my_head, state.previous_hazard_bits)
enemy_hazard = self._hazard_cost(enemy_head, state.previous_hazard_bits) enemy_hazard = self._hazard_cost(enemy_head, state.previous_hazard_bits)
length_score = (len(state.my_body) - len(state.enemy_body)) * 20.0 length_delta = len(state.my_body) - len(state.enemy_body)
length_score = length_delta * 20.0
health_score = (state.my_health - state.enemy_health) * 0.18 health_score = (state.my_health - state.enemy_health) * 0.18
forced_score = (my_liberties > 1) * 10.0 - (enemy_liberties > 1) * 10.0 forced_score = (my_liberties > 1) * 10.0 - (enemy_liberties > 1) * 10.0
return (
# Food races matter most when health is low or eating changes head-to-head
# priority. Compare actual path lengths rather than Manhattan distance so a
# food tile behind a body wall is not treated as reachable.
food_score = 0.0
if state.food_bits:
my_food = self.board.nearest_food(my_head, state.food_bits, my_blocked)
enemy_food = self.board.nearest_food(enemy_head, state.food_bits, my_blocked)
my_distance = my_food[0] if my_food[0] is not None else 200
enemy_distance = enemy_food[0] if enemy_food[0] is not None else 200
my_urgency = max(0.0, (55.0 - state.my_health) / 55.0)
enemy_urgency = max(0.0, (55.0 - state.enemy_health) / 55.0)
food_score += (enemy_distance - my_distance) * 2.5
food_score -= my_distance * my_urgency * 5.0
food_score += enemy_distance * enemy_urgency * 3.0
if length_delta == 0 and my_distance < enemy_distance:
food_score += 14.0
elif length_delta < 0 and my_distance <= enemy_distance:
food_score += 20.0
# Reward maintaining safe pressure around the opposing head. This captures
# two-turn head traps that raw territory and flood counts often score as a
# neutral position.
head_distance = self.board.path_distance(my_head, enemy_head, my_blocked)
pressure_score = 0.0
if head_distance is not None and head_distance <= 3:
pressure = (4 - head_distance) * 6.0
pressure_score = pressure if length_delta > 0 else -pressure if length_delta < 0 else 0.0
value = (
(my_space - enemy_space) * 1.5 + territory * 1.2 (my_space - enemy_space) * 1.5 + territory * 1.2
+ (my_liberties - enemy_liberties) * 14.0 + length_score + health_score + (my_liberties - enemy_liberties) * 14.0 + length_score + health_score
+ tail_score + forced_score + (enemy_hazard - my_hazard) * 0.8 + tail_score + forced_score + food_score + pressure_score
+ (enemy_hazard - my_hazard) * 0.8
) )
if len(self._evaluation_cache) < 32_768:
self._evaluation_cache[state] = value
return value
def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int: def _hazard_cost(self, target: int, previous_hazard_bits: int) -> int:
bit = 1 << target bit = 1 << target
@@ -400,4 +447,7 @@ class BitboardDuelSearch:
def _out_of_time(self, reserve_ms: float = 0.0) -> bool: def _out_of_time(self, reserve_ms: float = 0.0) -> bool:
if self.deadline is None: if self.deadline is None:
return False return False
return perf_counter() + reserve_ms / 1000.0 >= self.deadline expired = perf_counter() + reserve_ms / 1000.0 >= self.deadline
if expired:
self.deadline_exits += 1
return expired
+216
View File
@@ -0,0 +1,216 @@
"""Bitboard-backed spatial primitives shared by competitive snakes."""
from __future__ import annotations
from snakes.engine.bitboard import BitBoard
class BitboardSpatialMixin:
def _get_bb(self, width: int, height: int) -> BitBoard:
"""Return (possibly cached) BitBoard for the current dimensions."""
if self._bb is None or width != self._bb_w or height != self._bb_h:
self._bb = BitBoard(width, height)
self._bb_w = width
self._bb_h = height
return self._bb
def _blocked_to_bits(self, blocked: set[tuple[int, int]], width: int, height: int) -> int:
"""Convert blocked cells to bits without stale identity-based caching."""
return self._get_bb(width, height).set_to_bits(blocked)
def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
# A7/E2: per-turn transposition cache (kept from Apex)
cache_key = (start_idx, blocked_bits, width, height)
cached = self._bfs_cache.get(cache_key)
if cached is not None:
return cached
result = bb.flood_count(start_idx, blocked_bits)
if len(self._bfs_cache) < self._bfs_cache_max:
self._bfs_cache[cache_key] = result
return result
def _territory_fast(
self, my_pos: tuple, blocked: set, width: int, height: int,
deadline: float | None = None,
) -> int:
if not self._enemy_heads:
return 0
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
my_idx = bb.idx(my_pos[0], my_pos[1])
enemy_idxs = [bb.idx(eh[0], eh[1]) for eh in self._enemy_heads]
return bb.territory(my_idx, enemy_idxs, blocked_bits)
def _articulation_penalty(
self, point: tuple, blocked: set, width: int, height: int, required_space: int,
) -> float:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
point_idx = bb.idx(point[0], point[1])
sizes = bb.partition_sizes(point_idx, blocked_bits)
if not sizes:
return 0.0
min_size = min(sizes)
if min_size < required_space:
return 1500.0
elif min_size < required_space * 2:
return 400.0
else:
return 85.0
def _bounded_bfs(self, start: tuple, blocked: set, width: int, height: int, limit: int) -> set:
"""Bitboard-accelerated bounded BFS. Returns a set for API compatibility."""
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
reachable_bits = bb.flood_fill(start_idx, blocked_bits)
result: set[tuple[int, int]] = set()
temp = reachable_bits
w = bb.width
while temp:
bit = temp & (-temp)
idx = bit.bit_length() - 1
result.add((idx % w, idx // w))
temp ^= bit
if len(result) >= limit:
break
return result
def _distance_map(self, start: tuple, blocked: set, width: int, height: int) -> dict:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
start_idx = bb.idx(start[0], start[1])
idx_dmap = bb.distance_map(start_idx, blocked_bits)
w = bb.width
return {(idx % w, idx // w): d for idx, d in idx_dmap.items()}
def _path_distance(
self, start: tuple, goal: tuple, blocked: set, width: int, height: int,
) -> int | None:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.path_distance(
bb.idx(start[0], start[1]),
bb.idx(goal[0], goal[1]),
blocked_bits,
)
def _nearest_food_info(
self, start: tuple, food_set: set, blocked: set, width: int, height: int,
) -> tuple[int | None, tuple | None]:
if not food_set:
return None, None
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
food_bits = bb.set_to_bits(food_set)
start_idx = bb.idx(start[0], start[1])
dist, cell_idx = bb.nearest_food(start_idx, food_bits, blocked_bits)
if dist is None or cell_idx is None:
return None, None
return dist, bb.coord(cell_idx)
def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(start[0], start[1]), blocked_bits)
def _next_turn_options(self, head: dict, blocked: set, width: int, height: int) -> int:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
return bb.open_neighbor_count(bb.idx(head["x"], head["y"]), blocked_bits)
def _legal_moves(
self, my_head, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict | None = None,
):
"""S10: Bitboard-accelerated legal move generation."""
bb = self._get_bb(width, height)
w = bb.width
# Build occupied bitboard
occupied = 0
for seg in my_body:
occupied |= 1 << (seg["y"] * w + seg["x"])
for snake in other_snakes:
for seg in snake["body"]:
occupied |= 1 << (seg["y"] * w + seg["x"])
hx, hy = my_head["x"], my_head["y"]
head_idx = hy * w + hx
# Own tail can be stepped on
passable = 0
if not is_constrictor and len(my_body) >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy tails that will vacate
if not is_constrictor:
for snake in other_snakes:
sbody = snake["body"]
if len(sbody) < 2:
continue
st, st2 = sbody[-1], sbody[-2]
if st["x"] == st2["x"] and st["y"] == st2["y"]:
continue # stacked
sid = snake.get("id")
can_grow = None
if enemy_can_grow is not None and sid is not None:
can_grow = enemy_can_grow.get(sid)
if can_grow is None:
can_grow = self._enemy_can_grow_this_turn(snake, food_set)
if not can_grow:
passable |= 1 << (st["y"] * w + st["x"])
legal = bb._neighbor_masks[head_idx] & ((~occupied & bb.board_mask) | passable)
safe: dict[str, dict[str, int]] = {}
for name, (dx, dy) in self.DIRECTIONS.items():
nx, ny = hx + dx, hy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
if (1 << (ny * w + nx)) & legal:
safe[name] = {"x": nx, "y": ny}
return safe
def _enemy_confinement_metrics(
self, enemy_head: tuple, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
eh_idx = bb.idx(enemy_head[0], enemy_head[1])
eb_bits = blocked_bits & ~(1 << eh_idx)
space = bb.flood_count(eh_idx, eb_bits)
options = bb.open_neighbor_count(eh_idx, eb_bits)
return space, options
def _enemy_constrictor_projection(
self, other_snakes: list, blocked: set, width: int, height: int,
) -> tuple[int, int]:
bb = self._get_bb(width, height)
blocked_bits = self._blocked_to_bits(blocked, width, height)
best_space = 0
total_opts = 0
for enemy in other_snakes:
eh = (enemy["head"]["x"], enemy["head"]["y"])
eh_idx = bb.idx(eh[0], eh[1])
nb = bb.neighbors_of(eh_idx) & ~blocked_bits & bb.board_mask
temp = nb
while temp:
total_opts += 1
bit = temp & (-temp)
n_idx = bit.bit_length() - 1
sp = bb.flood_count(n_idx, blocked_bits | bit)
if sp > best_space:
best_space = sp
temp ^= bit
return best_space, total_opts
+214
View File
@@ -0,0 +1,214 @@
"""Reusable bitboard and adversarial survival rollouts."""
from __future__ import annotations
from time import perf_counter
from snakes.engine.survival_search import CompactSurvivalSearch
class BitboardSurvivalMixin:
def _future_rollout_bonus(
self, move: str, safe_moves: dict, my_body: list, other_snakes: list,
food_set: set, is_constrictor: bool, width: int, height: int,
enemy_can_grow: dict, deadline: float | None,
) -> float:
pos = safe_moves.get(move)
if pos is None:
return -250.0
# Duel minimax already advances the opponent exactly. Keep the much faster
# bitboard-native solo rollout here instead of paying for the same response
# model twice. Constrictor and multiplayer still use adversarial rollouts.
if len(other_snakes) == 1 and not is_constrictor:
return super()._future_rollout_bonus(
move, safe_moves, my_body, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
if self._survival_search_context is None:
remaining = self._remaining_ms(deadline)
enemy_branch = 2 if len(other_snakes) <= 2 and remaining > 100 else 1
self._survival_search_context = CompactSurvivalSearch(
board=self._get_bb(width, height),
food=food_set,
is_constrictor=is_constrictor,
deadline=deadline,
branch=self._planning_branch,
enemy_branch=enemy_branch,
response_cap=8 if remaining > 150 else 4,
)
remaining = self._remaining_ms(deadline)
depth = min(self._planning_depth, 2 if len(other_snakes) > 1 else 3)
if remaining < 90:
depth = min(depth, 2)
elif remaining > 250 and len(other_snakes) <= 2:
depth = min(4, depth + 1)
raw = self._survival_search_context.search_selected(
my_body=my_body,
enemies=other_snakes,
target=(pos["x"], pos["y"]),
depth=depth,
)
return raw * 0.15
def _future_position_score(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict, deadline: float | None,
) -> float:
"""S9: Bitboard-native position scoring for the survival tree.
Builds blocked bitboard directly from body lists (no intermediate set).
Uses precomputed enemy bits instead of rebuilding attack map per node.
"""
if deadline is not None and perf_counter() >= deadline:
return 0.0
bb = self._bb # already initialised in choose_move
w = bb.width
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
head_bit = 1 << head_idx
body_len = len(my_body)
# ── Build blocked bitboard directly (no set) ──────────────────────
my_bits = 0
for seg in my_body:
my_bits |= 1 << (seg["y"] * w + seg["x"])
# Own tail vacates unless stacked or constrictor
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
my_bits &= ~(1 << (t["y"] * w + t["x"]))
# Enemy body (precomputed) minus vacating tails
en_bits = self._enemy_body_bits & ~self._enemy_tail_bits
blocked_bits = (my_bits | en_bits) & ~head_bit
# ── Reachable space ───────────────────────────────────────────────
reachable = bb.flood_count(head_idx, blocked_bits)
required = body_len + max(3, body_len // 6) if is_constrictor else body_len
if reachable < required:
return -5000.0
# ── Open neighbours (liberties) ───────────────────────────────────
nb_free = bb._neighbor_masks[head_idx] & ~blocked_bits & bb.board_mask
liberties = nb_free.bit_count()
if liberties == 0:
return -5000.0
# ── Safe next options (enemy-attack aware) ────────────────────────
# Rebuild danger for the simulated length. The root-turn danger mask is
# stale after eating and includes enemy moves blocked in this future body.
danger_here = 0
for enemy in other_snakes:
enemy_len = enemy.get("length", len(enemy["body"]))
if enemy_len < body_len:
continue
enemy_head = enemy["head"]
enemy_idx = enemy_head["y"] * w + enemy_head["x"]
danger_here |= bb._neighbor_masks[enemy_idx]
danger_here &= ~blocked_bits
safe_nb = nb_free & ~danger_here
en_safe = safe_nb.bit_count()
if en_safe == 0:
return -4000.0
next_opts = liberties
sc = reachable * 1.9 + liberties * 14.0 + next_opts * 11.0 + en_safe * 26.0
if en_safe == 1:
sc -= 420.0
return sc
def _future_survival_tree(
self, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool,
width: int, height: int, enemy_can_grow: dict,
depth: int, branch: int, deadline: float | None,
) -> float:
"""S9/S11: Bitboard-accelerated survival tree.
Inlines legal-move check with bitboard ops instead of per-direction
Python loops. Uses the bitboard-native _future_position_score.
"""
if depth <= 0 or (deadline is not None and perf_counter() >= deadline):
return 0.0
bb = self._bb
w = bb.width
head = my_body[0]
hx, hy = head["x"], head["y"]
head_idx = hy * w + hx
body_len = len(my_body)
# ── Build occupied bitboard for legal-move check ──────────────────
occupied_bits = 0
for seg in my_body:
occupied_bits |= 1 << (seg["y"] * w + seg["x"])
occupied_bits |= self._enemy_body_bits
# Own tail can be stepped on if not stacked/constrictor
passable = 0
if not is_constrictor and body_len >= 2:
t, t2 = my_body[-1], my_body[-2]
if not (t["x"] == t2["x"] and t["y"] == t2["y"]):
passable |= 1 << (t["y"] * w + t["x"])
# Enemy vacating tails are also steppable
passable |= self._enemy_tail_bits
# Legal moves: free neighbours OR passable tiles
legal_bits = bb._neighbor_masks[head_idx] & ((~occupied_bits & bb.board_mask) | passable)
if not legal_bits:
return -5000.0
# ── Precompute food bitboard once ─────────────────────────────────
food_bits_local = 0
for fx, fy in food_set:
food_bits_local |= 1 << (fy * w + fx)
# ── Score each legal move ─────────────────────────────────────────
scored: list[tuple[float, list]] = []
temp = legal_bits
while temp:
if deadline is not None and perf_counter() >= deadline:
break
bit = temp & (-temp)
temp ^= bit
idx = bit.bit_length() - 1
nx, ny = idx % w, idx // w
pos = {"x": nx, "y": ny}
ate = bool(bit & food_bits_local)
fb = self._future_body(my_body, pos, ate, is_constrictor)
sc = self._future_position_score(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, deadline,
)
scored.append((sc, fb))
if not scored:
return -5000.0
DEATH = self._TREE_DEATH_THRESHOLD
viable = [(sc, fb) for sc, fb in scored if sc > DEATH]
if not viable:
return max(sc for sc, _ in scored)
viable.sort(key=lambda x: x[0], reverse=True)
if depth == 1:
return viable[0][0]
best = viable[0][0]
for sc, fb in viable[:branch]:
if deadline is not None and perf_counter() >= deadline:
break
cont = self._future_survival_tree(
fb, other_snakes, food_set, is_constrictor,
width, height, enemy_can_grow, depth - 1, branch, deadline,
)
total = sc + cont * 0.72
if total > best:
best = total
return best
@@ -5,7 +5,7 @@ from __future__ import annotations
from itertools import product from itertools import product
from time import perf_counter from time import perf_counter
from snakes.bitboard import BitBoard from snakes.engine.bitboard import BitBoard
Body = tuple[int, ...] Body = tuple[int, ...]
EnemyBodies = tuple[Body, ...] EnemyBodies = tuple[Body, ...]
@@ -42,6 +42,8 @@ class CompactSurvivalSearch:
self.body_bits_cache: dict[Body, int] = {} self.body_bits_cache: dict[Body, int] = {}
self.nodes = 0 self.nodes = 0
self.cache_hits = 0 self.cache_hits = 0
self.completed_depth = 0
self.deadline_exits = 0
def body_from_dicts(self, body: list[dict]) -> Body: def body_from_dicts(self, body: list[dict]) -> Body:
return tuple(self.board.idx(segment["x"], segment["y"]) for segment in body) return tuple(self.board.idx(segment["x"], segment["y"]) for segment in body)
@@ -58,17 +60,24 @@ class CompactSurvivalSearch:
target_idx = self.board.idx(*target) target_idx = self.board.idx(*target)
if not self.board.neighbors_of(mine[0]) & (1 << target_idx): if not self.board.neighbors_of(mine[0]) & (1 << target_idx):
return self.DEATH return self.DEATH
return self._selected_root(mine, enemy_bodies, self.food_bits, target_idx, depth) value, completed = self._selected_root(
mine, enemy_bodies, self.food_bits, target_idx, depth,
)
if completed:
self.completed_depth = max(self.completed_depth, depth)
return value
def _selected_root( def _selected_root(
self, mine: Body, enemies: EnemyBodies, food_bits: int, target: int, depth: int, self, mine: Body, enemies: EnemyBodies, food_bits: int, target: int, depth: int,
) -> float: ) -> tuple[float, bool]:
replies = self._enemy_responses(enemies, mine, target, food_bits) replies = self._enemy_responses(enemies, mine, target, food_bits)
if not replies: if not replies:
replies = [()] replies = [()]
worst = float("inf") worst = float("inf")
completed = True
for response in replies: for response in replies:
if self._out_of_time(): if self._out_of_time():
completed = False
break break
child = self._advance(mine, enemies, target, response, food_bits) child = self._advance(mine, enemies, target, response, food_bits)
if child is None: if child is None:
@@ -79,7 +88,8 @@ class CompactSurvivalSearch:
if depth > 1 and value > self.DEATH: if depth > 1 and value > self.DEATH:
value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72 value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72
worst = min(worst, value) worst = min(worst, value)
return self._evaluate(mine, enemies) if worst == float("inf") else worst value = self._evaluate(mine, enemies) if worst == float("inf") else worst
return value, completed
def _search(self, mine: Body, enemies: EnemyBodies, food_bits: int, depth: int) -> float: def _search(self, mine: Body, enemies: EnemyBodies, food_bits: int, depth: int) -> float:
self.nodes += 1 self.nodes += 1
@@ -260,4 +270,7 @@ class CompactSurvivalSearch:
bits ^= bit bits ^= bit
def _out_of_time(self) -> bool: def _out_of_time(self) -> bool:
return self.deadline is not None and perf_counter() >= self.deadline expired = self.deadline is not None and perf_counter() >= self.deadline
if expired:
self.deadline_exits += 1
return expired
@@ -7,7 +7,7 @@ import os
from quart_common.web.env import env_int from quart_common.web.env import env_int
from server.dataset.RLBootstrapDataset import RLBootstrapDataset from server.dataset.RLBootstrapDataset import RLBootstrapDataset
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
class BestBattleSnake(TemplateSnake): class BestBattleSnake(TemplateSnake):
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
from collections import deque from collections import deque
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
import random import random
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
import random import random
from scipy import spatial from scipy import spatial
@@ -1,4 +1,4 @@
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
class MasterSnake(TemplateSnake): class MasterSnake(TemplateSnake):
VERSION = "1.2.0" VERSION = "1.2.0"
@@ -29,8 +29,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from time import perf_counter from time import perf_counter
from snakes.ApexBattleSnake import ApexBattleSnake from snakes.strategies.apex import ApexBattleSnake
from snakes.bitboard import BitBoard from snakes.engine.bitboard import BitBoard
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
# Direction offsets for coord-dict → tuple conversion # Direction offsets for coord-dict → tuple conversion
@@ -3,7 +3,7 @@ from typing import Any
import random, json, os import random, json, os
from server.TrainBattleSnakeAI import MOVES, extract_feature_values from server.TrainBattleSnakeAI import MOVES, extract_feature_values
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
class TrainedBattleSnake(TemplateSnake): class TrainedBattleSnake(TemplateSnake):
VERSION = "0.1.0" VERSION = "0.1.0"
@@ -6,7 +6,7 @@ import heapq, os
from quart_common.web.env import env_int from quart_common.web.env import env_int
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
from server.dataset.RLBootstrapDataset import RLBootstrapDataset from server.dataset.RLBootstrapDataset import RLBootstrapDataset
+1
View File
@@ -0,0 +1 @@
"""Historical snake strategies retained for replay and comparison."""
+6
View File
@@ -0,0 +1,6 @@
"""Actively maintained competitive snake strategies."""
from snakes.strategies.apex import ApexBattleSnake
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
__all__ = ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol")
@@ -7,7 +7,7 @@ import heapq, os
from quart_common.web.env import env_int from quart_common.web.env import env_int
from server.dataset.RLBootstrapDataset import RLBootstrapDataset from server.dataset.RLBootstrapDataset import RLBootstrapDataset
from snakes.TemplateSnake import TemplateSnake from snakes.core.template import TemplateSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
class ApexBattleSnake(TemplateSnake): class ApexBattleSnake(TemplateSnake):
+169
View File
@@ -0,0 +1,169 @@
"""PrismBattleSnake_GPT_5_6_Sol v1.3.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
articulation detection, distance maps, pathfinding) replaced by a
bitboard engine that uses integer arithmetic instead of Python sets/deques.
Key speedups:
S1: Bitboard flood fill — replaces BFS deque+set with integer bit-expansion.
~60× faster per call, eliminates _neighbors() generator overhead.
S2: Bitboard territory — dual-BFS expansion on ints replaces per-cell
distance-map comparison loop.
S3: Bitboard articulation — partition sizes via bit-flood instead of
_bounded_bfs with sets.
S4: Bitboard distance map — BFS via bit-expansion + bit-extract.
S5: Bitboard path distance — early-exit BFS on ints.
S6: Bitboard nearest food — BFS food search on ints.
S7: Per-turn BitBoard instance cached for board dimensions.
S8: Blocked-set → bitboard conversion cached within a turn to avoid
redundant O(n) conversions for the same frozen set.
S9: Survival-tree uses bitboards natively — enemy body/attack bits
precomputed once at tree root, no per-node set/dict rebuilds.
S10: _legal_moves override uses bitboard neighbour mask instead of
per-direction Python loop + _in_bounds calls.
S11: _future_survival_tree inlines legal-move check with bitboard ops.
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
S14: Candidate duel moves and enemy replies resolve on the same root turn.
S15: Candidate moves share one duel transposition/search context per turn.
S16: Compact adversarial multiplayer rollout advances plausible enemy replies.
S17: Rollout memoization and adaptive depth spend time on ambiguous positions.
S18: Prism uses a deeper tactical horizon while retaining Apex's timeout reserve.
"""
from __future__ import annotations
from server.GameBoard import GameBoard
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel import BitboardDuelMixin
from snakes.engine.duel_search import BitboardDuelSearch
from snakes.engine.spatial import BitboardSpatialMixin
from snakes.engine.survival import BitboardSurvivalMixin
from snakes.engine.survival_search import CompactSurvivalSearch
from snakes.strategies.apex import ApexBattleSnake
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class PrismBattleSnake_GPT_5_6_Sol(
BitboardDuelMixin,
BitboardSurvivalMixin,
BitboardSpatialMixin,
ApexBattleSnake,
):
VERSION = "1.3.0"
def __init__(self) -> None:
super().__init__()
self.name = "PrismBattleSnake"
self.version = self.VERSION
# Prism's compact state search is fast enough to inspect one additional
# turn. The existing deadline checks and Apex timeout reserve still cap the
# work on difficult positions.
self._planning_depth = max(self._planning_depth, 4)
# S7: cached BitBoard instance (reused while board dimensions stay the same)
self._bb: BitBoard | None = None
self._bb_w: int = 0
self._bb_h: int = 0
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# Shared per-turn search contexts. Candidate moves overlap heavily, so
# rebuilding their transposition tables wastes most iterative-deepening work.
self._duel_search_context: BitboardDuelSearch | None = None
self._survival_search_context: CompactSurvivalSearch | None = None
# ── choose_move override: precompute enemy bits ──────────────────────────
def choose_move(self, game_data: GameBoard) -> str:
bb = self._get_bb(game_data.get_width(), game_data.get_height())
self._duel_search_context = None
self._survival_search_context = None
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
my_snake = game_data.get_my_snake()
my_len = my_snake.get("length", len(my_snake["body"]))
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
all_occupied = {
(seg["x"], seg["y"])
for snake in [my_snake, *other_snakes]
for seg in snake["body"]
}
game_type = game_data.get_type()
is_constrictor = game_type == "constrictor"
w = bb.width
enemy_body_bits = 0
enemy_tail_bits = 0
enemy_attack_danger = 0
enemy_attack_opportunity = 0
for snake in other_snakes:
for seg in snake["body"]:
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
body = snake["body"]
# Check if tail will vacate
if not is_constrictor and len(body) >= 2:
tail_stacked = (
body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"]
)
if not tail_stacked:
can_grow = self._enemy_can_grow_this_turn(
snake, food_set, all_occupied
)
if not can_grow:
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
# Attack map: tiles enemy head can reach in 1 move
eh = snake["head"]
e_len = snake.get("length", len(body))
ehx, ehy = eh["x"], eh["y"]
for dx, dy in _DIR_DELTAS:
nx, ny = ehx + dx, ehy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
bit = 1 << (ny * w + nx)
if e_len >= my_len:
enemy_attack_danger |= bit
else:
enemy_attack_opportunity |= bit
self._enemy_body_bits = enemy_body_bits
self._enemy_tail_bits = enemy_tail_bits
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
move = super().choose_move(game_data)
history = self.get_history()
if history:
thinking = history[-1]
if self._duel_search_context is not None:
thinking["prism_duel_depth"] = self._duel_search_context.completed_depth
thinking["prism_duel_nodes"] = self._duel_search_context.nodes
thinking["prism_duel_cache_hits"] = (
self._duel_search_context.cache_hits
+ self._duel_search_context.evaluation_cache_hits
)
thinking["prism_duel_deadline_exits"] = (
self._duel_search_context.deadline_exits
)
if self._survival_search_context is not None:
thinking["prism_rollout_depth"] = (
self._survival_search_context.completed_depth
)
thinking["prism_rollout_nodes"] = self._survival_search_context.nodes
thinking["prism_rollout_cache_hits"] = (
self._survival_search_context.cache_hits
)
thinking["prism_rollout_deadline_exits"] = (
self._survival_search_context.deadline_exits
)
return move
+1 -1
View File
@@ -3,7 +3,7 @@ import argparse
import time import time
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
from snakes.BestBattleSnake import BestBattleSnake from snakes.legacy.BestBattleSnake import BestBattleSnake
def build_game_state() -> dict: def build_game_state() -> dict:
return { return {
@@ -0,0 +1,54 @@
import io
import unittest
from unittest.mock import patch
from scripts.run_seeded_snake_tournament import ENGINE_USER_AGENT, run_game
class _Completed:
returncode = 0
stdout = "INFO Game completed after 42 turns. Prism was the winner.\n"
class _OutputFile:
def __init__(self, *args, **kwargs):
self.file = io.BytesIO(
b'{"turn":0}\n'
b'{"winnerId":"snake-id","winnerName":"Prism","isDraw":false}\n'
)
self.name = "arena-output.jsonl"
def __enter__(self):
return self
def __exit__(self, *args):
self.file.close()
def seek(self, offset):
return self.file.seek(offset)
def __iter__(self):
return iter(self.file)
class TestSeededSnakeTournament(unittest.TestCase):
def test_proxy_identifies_requests_as_battlesnake_engine(self):
self.assertIn("BattlesnakeEngine", ENGINE_USER_AGENT)
@patch("scripts.run_seeded_snake_tournament.subprocess.run", return_value=_Completed())
@patch("scripts.run_seeded_snake_tournament.tempfile.NamedTemporaryFile", _OutputFile)
def test_run_game_reads_official_engine_result(self, run):
result = run_game(
cli="battlesnake", seed=7, game_type="standard", map_name="standard",
players=[("Apex", "http://host:9001"), ("Prism", "http://host:9002")],
width=11, height=11, timeout_ms=500,
)
self.assertEqual(result, {
"seed": 7, "winner": "Prism", "draw": False, "turns": 42,
})
command = run.call_args.args[0]
self.assertIn("--seed", command)
self.assertIn("--output", command)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,31 @@
import unittest
from scripts.snake_arena_scenarios import SCENARIOS, synthetic_states
class TestSnakeArenaScenarios(unittest.TestCase):
def test_default_corpus_rotates_through_every_scenario(self):
states = synthetic_states(len(SCENARIOS))
self.assertEqual(
{metadata["scenario"] for _, metadata in states},
set(SCENARIOS),
)
def test_scenario_filter_is_deterministic(self):
first = synthetic_states(3, ["hazard"])
second = synthetic_states(3, ["hazard"])
self.assertEqual(first, second)
self.assertTrue(all(metadata["scenario"] == "hazard" for _, metadata in first))
def test_generated_you_is_present_on_board(self):
for board, metadata in synthetic_states(20):
with self.subTest(scenario=metadata["scenario"]):
ids = {snake["id"] for snake in board["snakes"]}
self.assertIn(metadata["you"]["id"], ids)
self.assertGreater(board["width"], 0)
self.assertGreater(board["height"], 0)
if __name__ == "__main__":
unittest.main()
@@ -2,11 +2,11 @@ import unittest
from time import perf_counter from time import perf_counter
from snakes import SnakeBuilder, get_snake_version from snakes import SnakeBuilder, get_snake_version
from snakes.ApexBattleSnake import ApexBattleSnake from snakes.engine.bitboard import BitBoard
from snakes.bitboard import BitBoard from snakes.engine.duel_search import BitboardDuelSearch
from snakes.bitboard_duel_search import BitboardDuelSearch from snakes.engine.survival_search import CompactSurvivalSearch
from snakes.compact_survival_search import CompactSurvivalSearch from snakes.strategies.apex import ApexBattleSnake
from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
class TestBitBoard(unittest.TestCase): class TestBitBoard(unittest.TestCase):
@@ -52,8 +52,9 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
snake = PrismBattleSnake_GPT_5_6_Sol() snake = PrismBattleSnake_GPT_5_6_Sol()
self.assertEqual(snake.name, "PrismBattleSnake") self.assertEqual(snake.name, "PrismBattleSnake")
self.assertEqual(snake.version, "1.2.0") self.assertEqual(snake.version, "1.3.0")
self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.2.0") self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.3.0")
self.assertGreaterEqual(snake._planning_depth, 4)
self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol) self.assertIsInstance(SnakeBuilder.build("PrismBattleSnake_GPT_5_6_Sol"), PrismBattleSnake_GPT_5_6_Sol)
def test_bitboard_primitives_match_apex(self): def test_bitboard_primitives_match_apex(self):
@@ -175,6 +176,21 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
self.assertIs(snake._duel_search_context, first_context) self.assertIs(snake._duel_search_context, first_context)
self.assertGreater(first_context.nodes, 0) self.assertGreater(first_context.nodes, 0)
self.assertGreater(first_context.completed_depth, 0)
def test_duel_evaluation_prioritizes_reachable_food_when_starving(self):
board = BitBoard(5, 3)
search = BitboardDuelSearch(
board=board, food={(2, 1)}, hazards=set(), hazard_count={},
hazard_damage=15, deadline=None,
)
my_body = [{"x": 0, "y": 1}, {"x": 0, "y": 0}]
enemy_body = [{"x": 4, "y": 1}, {"x": 4, "y": 0}]
hungry = search.search_depth(my_body, enemy_body, 15, 100, 0, set())
healthy = search.search_depth(my_body, enemy_body, 100, 100, 0, set())
self.assertLess(hungry, healthy)
def test_compact_rollout_models_lethal_enemy_head_response(self): def test_compact_rollout_models_lethal_enemy_head_response(self):
board = BitBoard(3, 3) board = BitBoard(3, 3)
@@ -205,6 +221,7 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase):
search.search_selected(mine, enemies, (2, 1), depth=3) search.search_selected(mine, enemies, (2, 1), depth=3)
self.assertGreater(search.cache_hits, hits_before) self.assertGreater(search.cache_hits, hits_before)
self.assertGreaterEqual(search.completed_depth, 3)
def test_bitboard_duel_search_reuses_transpositions(self): def test_bitboard_duel_search_reuses_transpositions(self):
board = BitBoard(5, 5) board = BitBoard(5, 5)
+35
View File
@@ -0,0 +1,35 @@
import unittest
from snakes import SNAKE_REGISTRATIONS, SnakeBuilder
from snakes.engine import (
BitBoard,
BitboardDuelMixin,
BitboardSpatialMixin,
BitboardSurvivalMixin,
)
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
class TestSnakePackageLayout(unittest.TestCase):
def test_registry_uses_explicit_package_modules(self):
for name, registration in SNAKE_REGISTRATIONS.items():
with self.subTest(name=name):
self.assertTrue(registration.module.startswith("snakes."))
self.assertNotEqual(registration.module, f"snakes.{name}")
def test_registry_builds_active_strategies_after_package_move(self):
for name in ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol"):
with self.subTest(name=name):
snake = SnakeBuilder.build(name)
self.assertEqual(snake.__class__.__name__, name)
def test_prism_composes_reusable_engine_mixins(self):
snake = PrismBattleSnake_GPT_5_6_Sol()
self.assertIsInstance(snake, BitboardDuelMixin)
self.assertIsInstance(snake, BitboardSpatialMixin)
self.assertIsInstance(snake, BitboardSurvivalMixin)
self.assertIsInstance(snake._get_bb(11, 11), BitBoard)
if __name__ == "__main__":
unittest.main()
+3 -3
View File
@@ -5,8 +5,8 @@ and that the bitboard engine itself is sound.
""" """
import unittest import unittest
from snakes.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake from snakes.legacy.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake
from snakes.bitboard import BitBoard from snakes.engine.bitboard import BitBoard
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@@ -348,7 +348,7 @@ class TestParityWithApex(unittest.TestCase):
def test_trapped_corner(self): def test_trapped_corner(self):
"""Both snakes should survive a forced single-exit scenario.""" """Both snakes should survive a forced single-exit scenario."""
from snakes.ApexBattleSnake import ApexBattleSnake from snakes.strategies.apex import ApexBattleSnake
state = gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)], state = gs(my_body=[(1, 1), (1, 2), (2, 2), (2, 1)],
other_bodies=[], foods=[(5, 5)], width=7, height=7) other_bodies=[], foods=[(5, 5)], width=7, height=7)
+1 -1
View File
@@ -1,6 +1,6 @@
import unittest import unittest
from snakes.UltimateBattleSnake import UltimateBattleSnake from snakes.legacy.UltimateBattleSnake import UltimateBattleSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
+1 -1
View File
@@ -1,6 +1,6 @@
import unittest import unittest
from snakes.BestBattleSnake import BestBattleSnake from snakes.legacy.BestBattleSnake import BestBattleSnake
from server.GameBoard import GameBoard from server.GameBoard import GameBoard
def make_board(game_state): def make_board(game_state):
+1 -1
View File
@@ -1,5 +1,5 @@
import unittest import unittest
from snakes.MasterSnake import MasterSnake from snakes.legacy.MasterSnake import MasterSnake
class TestMasterSnake(unittest.TestCase): class TestMasterSnake(unittest.TestCase):
def setUp(self): def setUp(self):
+1 -1
View File
@@ -12,7 +12,7 @@ in the folder where this file exists:
""" """
import unittest import unittest
from snakes.LogicSnake import avoid_my_neck from snakes.legacy.LogicSnake import avoid_my_neck
class AvoidNeckTest(unittest.TestCase): class AvoidNeckTest(unittest.TestCase):