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:
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
|
||||||
+43
-22
@@ -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):
|
def build_snake(selected_snake: str):
|
||||||
if selected_snake not in SNAKE_REGISTRY:
|
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
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Shared snake base classes."""
|
||||||
|
|
||||||
|
from snakes.core.template import TemplateSnake
|
||||||
|
|
||||||
|
__all__ = ("TemplateSnake",)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
+2
-2
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Historical snake strategies retained for replay and comparison."""
|
||||||
@@ -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):
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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,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,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,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
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user