diff --git a/README.md b/README.md index 1d0649b..edf47d8 100644 --- a/README.md +++ b/README.md @@ -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. ## 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 - flood-fill space evaluation to avoid traps - 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`. +## 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` is a separate snake that keeps Apex's strategy while accelerating hot spatial operations with a Python-integer bitboard engine. It also shares duel transpositions across candidate moves, uses principal-variation -ordering and aspiration windows, and runs a compact adversarial multiplayer -rollout with simultaneous enemy responses. Its filename, class, and registry +ordering, aspiration windows, path-aware food races, and a deeper tactical +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 `PrismBattleSnake`. @@ -95,11 +106,24 @@ Run the deterministic CI-friendly arena benchmark without a gameplay database: just bench-snake-arena positions=100 ``` -It reports latency, reached minimax depth, and move disagreements between Apex -and Prism. Add `output=data/arena-report.json` to save a machine-readable report. +It rotates through duel, hazard, multiplayer, constrictor, and cramped-endgame +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 -`scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`, then run -paired seeded games with the local Battlesnake CLI to measure win rate. +`scripts/benchmark_snake_arena.py --database /path/to/gameplay.sqlite3`. + +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 New gameplay turns use normalized storage: the turn row stores food, hazards, diff --git a/justfile b/justfile index 81c4bd5..809f64b 100644 --- a/justfile +++ b/justfile @@ -70,6 +70,14 @@ bench-snake-arena positions="100" output="": if [ -n "{{output}}" ]; then args+=(--json-output "{{output}}"); fi 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: #!/usr/bin/env bash set -euo pipefail diff --git a/scripts/benchmark_snake_arena.py b/scripts/benchmark_snake_arena.py index 09e9a89..4b0dab3 100755 --- a/scripts/benchmark_snake_arena.py +++ b/scripts/benchmark_snake_arena.py @@ -17,34 +17,20 @@ from time import perf_counter sys.path.insert(0, str(Path(__file__).resolve().parents[1])) 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 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]: moves: list[str] = [] 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): snake = SnakeBuilder.build(name) 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() 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 [] 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, { "snake": name, "positions": len(states), "mean_ms": mean(durations), "median_ms": median(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: @@ -79,12 +82,13 @@ def main() -> None: parser.add_argument("--database") parser.add_argument("--positions", type=int, default=100) parser.add_argument("--stride", type=int, default=997) + parser.add_argument("--scenario", action="append", choices=sorted(SCENARIOS)) parser.add_argument("--json-output") args = parser.parse_args() states = ( 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: raise SystemExit("No benchmark positions found") @@ -99,7 +103,10 @@ def main() -> None: print( f"{name}: mean={report['mean_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] disagreements = { @@ -109,7 +116,25 @@ def main() -> None: if 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: Path(args.json_output).write_text(json.dumps(payload, indent=2) + "\n") diff --git a/scripts/run_seeded_snake_tournament.py b/scripts/run_seeded_snake_tournament.py new file mode 100644 index 0000000..697d2dc --- /dev/null +++ b/scripts/run_seeded_snake_tournament.py @@ -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() diff --git a/scripts/snake_arena_scenarios.py b/scripts/snake_arena_scenarios.py new file mode 100644 index 0000000..56f9afc --- /dev/null +++ b/scripts/snake_arena_scenarios.py @@ -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 diff --git a/server/GameBoard.py b/server/GameBoard.py index 80839f5..e8bcb2a 100644 --- a/server/GameBoard.py +++ b/server/GameBoard.py @@ -1,4 +1,4 @@ -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake from datetime import datetime class GameBoard: diff --git a/snakes/PrismBattleSnake_GPT_5_6_Sol.py b/snakes/PrismBattleSnake_GPT_5_6_Sol.py deleted file mode 100644 index bbeab60..0000000 --- a/snakes/PrismBattleSnake_GPT_5_6_Sol.py +++ /dev/null @@ -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 diff --git a/snakes/__init__.py b/snakes/__init__.py index 34685e4..71c7dc9 100644 --- a/snakes/__init__.py +++ b/snakes/__init__.py @@ -1,40 +1,61 @@ 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 = { - "TemplateSnake": "1.0.0", - "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", + name: registration.version for name, registration in SNAKE_REGISTRATIONS.items() } DEFAULT_SNAKE_CONFIG = { - 'apiversion': '1', - 'author': '', - 'color': '#888888', - 'head': 'default', - 'tail': 'default', + "apiversion": "1", + "author": "", + "color": "#888888", + "head": "default", + "tail": "default", } -def build_snake(selected_snake:str): - if selected_snake not in SNAKE_REGISTRY: + +def build_snake(selected_snake: str): + registration = SNAKE_REGISTRATIONS.get(selected_snake) + if registration is None: raise ValueError(f"Unknown snake: {selected_snake}") - snake_module = importlib.import_module(f"snakes.{selected_snake}") + snake_module = importlib.import_module(registration.module) snake_class = getattr(snake_module, selected_snake) return snake_class() -def get_snake_version(selected_snake:str) -> str|None: - version = SNAKE_REGISTRY.get(selected_snake) - if version is None: - return None - return str(version) +def get_snake_version(selected_snake: str) -> str | None: + registration = SNAKE_REGISTRATIONS.get(selected_snake) + return registration.version if registration is not None else None + class SnakeBuilder: @classmethod @@ -42,5 +63,5 @@ class SnakeBuilder: return build_snake(selected_snake) @classmethod - def get_version(self, selected_snake:str) -> str|None: + def get_version(self, selected_snake: str) -> str | None: return get_snake_version(selected_snake) diff --git a/snakes/core/__init__.py b/snakes/core/__init__.py new file mode 100644 index 0000000..c03ac9c --- /dev/null +++ b/snakes/core/__init__.py @@ -0,0 +1,5 @@ +"""Shared snake base classes.""" + +from snakes.core.template import TemplateSnake + +__all__ = ("TemplateSnake",) diff --git a/snakes/TemplateSnake.py b/snakes/core/template.py similarity index 100% rename from snakes/TemplateSnake.py rename to snakes/core/template.py diff --git a/snakes/engine/__init__.py b/snakes/engine/__init__.py new file mode 100644 index 0000000..7eb7a04 --- /dev/null +++ b/snakes/engine/__init__.py @@ -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", +) diff --git a/snakes/bitboard.py b/snakes/engine/bitboard.py similarity index 100% rename from snakes/bitboard.py rename to snakes/engine/bitboard.py diff --git a/snakes/engine/duel.py b/snakes/engine/duel.py new file mode 100644 index 0000000..809b6a3 --- /dev/null +++ b/snakes/engine/duel.py @@ -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, + ) diff --git a/snakes/bitboard_duel_search.py b/snakes/engine/duel_search.py similarity index 84% rename from snakes/bitboard_duel_search.py rename to snakes/engine/duel_search.py index 1732d65..a19d7b7 100644 --- a/snakes/bitboard_duel_search.py +++ b/snakes/engine/duel_search.py @@ -6,7 +6,7 @@ from collections.abc import Iterable from dataclasses import dataclass from time import perf_counter -from snakes.bitboard import BitBoard +from snakes.engine.bitboard import BitBoard Body = tuple[int, ...] @@ -51,8 +51,12 @@ class BitboardDuelSearch: self.killer_moves: dict[int, int] = {} self.history: dict[int, int] = {} self._body_bits_cache: dict[Body, int] = {} + self._evaluation_cache: dict[DuelState, float] = {} self.nodes = 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: return tuple(self.board.idx(seg["x"], seg["y"]) for seg in body) @@ -90,6 +94,7 @@ class BitboardDuelSearch: result = value completed_depth = depth + self.completed_depth = max(self.completed_depth, completed_depth) return result, completed_depth def search_candidate( @@ -135,6 +140,7 @@ class BitboardDuelSearch: break result = value completed_depth = depth + self.completed_depth = max(self.completed_depth, completed_depth) return result, completed_depth def search_depth( @@ -154,7 +160,9 @@ class BitboardDuelSearch: enemy_health=enemy_health, 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 def _search_selected_move( @@ -344,6 +352,11 @@ class BitboardDuelSearch: return sorted(moves, key=score, reverse=True) 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_head, enemy_head = state.my_body[0], state.enemy_body[0] 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) my_hazard = self._hazard_cost(my_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 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_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: bit = 1 << target @@ -400,4 +447,7 @@ class BitboardDuelSearch: def _out_of_time(self, reserve_ms: float = 0.0) -> bool: if self.deadline is None: 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 diff --git a/snakes/engine/spatial.py b/snakes/engine/spatial.py new file mode 100644 index 0000000..d408a9c --- /dev/null +++ b/snakes/engine/spatial.py @@ -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 diff --git a/snakes/engine/survival.py b/snakes/engine/survival.py new file mode 100644 index 0000000..21df68f --- /dev/null +++ b/snakes/engine/survival.py @@ -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 diff --git a/snakes/compact_survival_search.py b/snakes/engine/survival_search.py similarity index 93% rename from snakes/compact_survival_search.py rename to snakes/engine/survival_search.py index 86fbd01..caa5c28 100644 --- a/snakes/compact_survival_search.py +++ b/snakes/engine/survival_search.py @@ -5,7 +5,7 @@ from __future__ import annotations from itertools import product from time import perf_counter -from snakes.bitboard import BitBoard +from snakes.engine.bitboard import BitBoard Body = tuple[int, ...] EnemyBodies = tuple[Body, ...] @@ -42,6 +42,8 @@ class CompactSurvivalSearch: self.body_bits_cache: dict[Body, int] = {} self.nodes = 0 self.cache_hits = 0 + self.completed_depth = 0 + self.deadline_exits = 0 def body_from_dicts(self, body: list[dict]) -> 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) if not self.board.neighbors_of(mine[0]) & (1 << target_idx): 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( 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) if not replies: replies = [()] worst = float("inf") + completed = True for response in replies: if self._out_of_time(): + completed = False break child = self._advance(mine, enemies, target, response, food_bits) if child is None: @@ -79,7 +88,8 @@ class CompactSurvivalSearch: if depth > 1 and value > self.DEATH: value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72 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: self.nodes += 1 @@ -260,4 +270,7 @@ class CompactSurvivalSearch: bits ^= bit 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 diff --git a/snakes/BestBattleSnake.py b/snakes/legacy/BestBattleSnake.py similarity index 99% rename from snakes/BestBattleSnake.py rename to snakes/legacy/BestBattleSnake.py index b31358d..a80da03 100644 --- a/snakes/BestBattleSnake.py +++ b/snakes/legacy/BestBattleSnake.py @@ -7,7 +7,7 @@ import os from quart_common.web.env import env_int from server.dataset.RLBootstrapDataset import RLBootstrapDataset -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake from server.GameBoard import GameBoard class BestBattleSnake(TemplateSnake): diff --git a/snakes/BetterMasterSnake.py b/snakes/legacy/BetterMasterSnake.py similarity index 99% rename from snakes/BetterMasterSnake.py rename to snakes/legacy/BetterMasterSnake.py index 14724a3..4a5aa43 100644 --- a/snakes/BetterMasterSnake.py +++ b/snakes/legacy/BetterMasterSnake.py @@ -1,4 +1,4 @@ -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake from server.GameBoard import GameBoard from collections import deque diff --git a/snakes/DummSnake.py b/snakes/legacy/DummSnake.py similarity index 97% rename from snakes/DummSnake.py rename to snakes/legacy/DummSnake.py index c9e616f..457cfd7 100644 --- a/snakes/DummSnake.py +++ b/snakes/legacy/DummSnake.py @@ -1,4 +1,4 @@ -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake import random diff --git a/snakes/LogicSnake.py b/snakes/legacy/LogicSnake.py similarity index 99% rename from snakes/LogicSnake.py rename to snakes/legacy/LogicSnake.py index 3f3f8ea..37e7b4c 100644 --- a/snakes/LogicSnake.py +++ b/snakes/legacy/LogicSnake.py @@ -1,4 +1,4 @@ -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake import random from scipy import spatial diff --git a/snakes/MasterSnake.py b/snakes/legacy/MasterSnake.py similarity index 99% rename from snakes/MasterSnake.py rename to snakes/legacy/MasterSnake.py index 9e60678..6ca6385 100644 --- a/snakes/MasterSnake.py +++ b/snakes/legacy/MasterSnake.py @@ -1,4 +1,4 @@ -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake class MasterSnake(TemplateSnake): VERSION = "1.2.0" diff --git a/snakes/SupremeBattleSnake_ClaudeOpus4_6.py b/snakes/legacy/SupremeBattleSnake_ClaudeOpus4_6.py similarity index 99% rename from snakes/SupremeBattleSnake_ClaudeOpus4_6.py rename to snakes/legacy/SupremeBattleSnake_ClaudeOpus4_6.py index c061254..82cfc75 100644 --- a/snakes/SupremeBattleSnake_ClaudeOpus4_6.py +++ b/snakes/legacy/SupremeBattleSnake_ClaudeOpus4_6.py @@ -29,8 +29,8 @@ from __future__ import annotations from typing import Any from time import perf_counter -from snakes.ApexBattleSnake import ApexBattleSnake -from snakes.bitboard import BitBoard +from snakes.strategies.apex import ApexBattleSnake +from snakes.engine.bitboard import BitBoard from server.GameBoard import GameBoard # Direction offsets for coord-dict → tuple conversion diff --git a/snakes/TrainedBattleSnake.py b/snakes/legacy/TrainedBattleSnake.py similarity index 98% rename from snakes/TrainedBattleSnake.py rename to snakes/legacy/TrainedBattleSnake.py index 32679ed..189de65 100644 --- a/snakes/TrainedBattleSnake.py +++ b/snakes/legacy/TrainedBattleSnake.py @@ -3,7 +3,7 @@ from typing import Any import random, json, os from server.TrainBattleSnakeAI import MOVES, extract_feature_values -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake class TrainedBattleSnake(TemplateSnake): VERSION = "0.1.0" diff --git a/snakes/UltimateBattleSnake.py b/snakes/legacy/UltimateBattleSnake.py similarity index 99% rename from snakes/UltimateBattleSnake.py rename to snakes/legacy/UltimateBattleSnake.py index a174f3b..edd00f3 100644 --- a/snakes/UltimateBattleSnake.py +++ b/snakes/legacy/UltimateBattleSnake.py @@ -6,7 +6,7 @@ import heapq, os 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.dataset.RLBootstrapDataset import RLBootstrapDataset diff --git a/snakes/legacy/__init__.py b/snakes/legacy/__init__.py new file mode 100644 index 0000000..41b1f0d --- /dev/null +++ b/snakes/legacy/__init__.py @@ -0,0 +1 @@ +"""Historical snake strategies retained for replay and comparison.""" diff --git a/snakes/strategies/__init__.py b/snakes/strategies/__init__.py new file mode 100644 index 0000000..d65bc9b --- /dev/null +++ b/snakes/strategies/__init__.py @@ -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") diff --git a/snakes/ApexBattleSnake.py b/snakes/strategies/apex.py similarity index 98% rename from snakes/ApexBattleSnake.py rename to snakes/strategies/apex.py index 0fd3280..25c5645 100644 --- a/snakes/ApexBattleSnake.py +++ b/snakes/strategies/apex.py @@ -7,7 +7,7 @@ import heapq, os from quart_common.web.env import env_int from server.dataset.RLBootstrapDataset import RLBootstrapDataset -from snakes.TemplateSnake import TemplateSnake +from snakes.core.template import TemplateSnake from server.GameBoard import GameBoard class ApexBattleSnake(TemplateSnake): @@ -18,20 +18,20 @@ class ApexBattleSnake(TemplateSnake): New improvements: A1: Iterative deepening minimax — tries depth 1,2,...,N within time budget; keeps deepest - fully-completed result instead of a fixed depth=2 call. + fully-completed result instead of a fixed depth=2 call. A2: Hazard-aware starvation check — Dijkstra with per-tile hazard cost replaces BFS food - distance when hazards are present and health < 55. Correctly models health depletion - through hazard corridors when choosing whether to seek food. + distance when hazards are present and health < 55. Correctly models health depletion + through hazard corridors when choosing whether to seek food. A3: Phase-adaptive scoring weights — board occupancy drives a game_phase scalar [0,1]. - Territory weight scales up late-game; food bias scales down. Stored as self._game_phase. + Territory weight scales up late-game; food bias scales down. Stored as self._game_phase. A4: Rich GameplayDatabase thinking data — add_to_history records game_phase, food_count, - enemy lengths/healths, minimax_depth_reached, score_gap, safe_moves_count per turn. + enemy lengths/healths, minimax_depth_reached, score_gap, safe_moves_count per turn. A5: Dynamic duel aggression — auto-adjusts head_pressure/distance_safety multipliers based - on (my_len - enemy_len) delta on top of the configured duel style preset. + on (my_len - enemy_len) delta on top of the configured duel style preset. A6: Constrictor endgame encirclement — when enemy is sealed in a region <= our body length, - apply a strong encirclement bonus to close out the win efficiently. + apply a strong encirclement bonus to close out the win efficiently. A7: Bounded BFS transposition cache — caps per-turn cache at 4096 entries to prevent - memory growth in long games with many unique blocked-set combinations. + memory growth in long games with many unique blocked-set combinations. """ VERSION = "1.0.0" diff --git a/snakes/strategies/prism.py b/snakes/strategies/prism.py new file mode 100644 index 0000000..c279e66 --- /dev/null +++ b/snakes/strategies/prism.py @@ -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 diff --git a/tests/bench_best_battle_snake.py b/tests/bench_best_battle_snake.py index 116a330..7bfe048 100644 --- a/tests/bench_best_battle_snake.py +++ b/tests/bench_best_battle_snake.py @@ -3,7 +3,7 @@ import argparse import time from server.GameBoard import GameBoard -from snakes.BestBattleSnake import BestBattleSnake +from snakes.legacy.BestBattleSnake import BestBattleSnake def build_game_state() -> dict: return { diff --git a/tests/scripts/test_run_seeded_snake_tournament.py b/tests/scripts/test_run_seeded_snake_tournament.py new file mode 100644 index 0000000..08b4409 --- /dev/null +++ b/tests/scripts/test_run_seeded_snake_tournament.py @@ -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() diff --git a/tests/scripts/test_snake_arena_scenarios.py b/tests/scripts/test_snake_arena_scenarios.py new file mode 100644 index 0000000..dbb8c46 --- /dev/null +++ b/tests/scripts/test_snake_arena_scenarios.py @@ -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() diff --git a/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py b/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py index 4bb28f4..9ee14b2 100644 --- a/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py +++ b/tests/snakes/test_PrismBattleSnake_GPT_5_6_Sol.py @@ -2,11 +2,11 @@ import unittest from time import perf_counter from snakes import SnakeBuilder, get_snake_version -from snakes.ApexBattleSnake import ApexBattleSnake -from snakes.bitboard import BitBoard -from snakes.bitboard_duel_search import BitboardDuelSearch -from snakes.compact_survival_search import CompactSurvivalSearch -from snakes.PrismBattleSnake_GPT_5_6_Sol import PrismBattleSnake_GPT_5_6_Sol +from snakes.engine.bitboard import BitBoard +from snakes.engine.duel_search import BitboardDuelSearch +from snakes.engine.survival_search import CompactSurvivalSearch +from snakes.strategies.apex import ApexBattleSnake +from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol class TestBitBoard(unittest.TestCase): @@ -52,8 +52,9 @@ class TestPrismBattleSnake_GPT_5_6_Sol(unittest.TestCase): snake = PrismBattleSnake_GPT_5_6_Sol() self.assertEqual(snake.name, "PrismBattleSnake") - self.assertEqual(snake.version, "1.2.0") - self.assertEqual(get_snake_version("PrismBattleSnake_GPT_5_6_Sol"), "1.2.0") + self.assertEqual(snake.version, "1.3.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) 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.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): 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) self.assertGreater(search.cache_hits, hits_before) + self.assertGreaterEqual(search.completed_depth, 3) def test_bitboard_duel_search_reuses_transpositions(self): board = BitBoard(5, 5) diff --git a/tests/snakes/test_SnakePackageLayout.py b/tests/snakes/test_SnakePackageLayout.py new file mode 100644 index 0000000..78812d5 --- /dev/null +++ b/tests/snakes/test_SnakePackageLayout.py @@ -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() diff --git a/tests/snakes/test_SupremeBattleSnake.py b/tests/snakes/test_SupremeBattleSnake.py index 8f229c1..62d7197 100644 --- a/tests/snakes/test_SupremeBattleSnake.py +++ b/tests/snakes/test_SupremeBattleSnake.py @@ -5,8 +5,8 @@ and that the bitboard engine itself is sound. """ import unittest -from snakes.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake -from snakes.bitboard import BitBoard +from snakes.legacy.SupremeBattleSnake_ClaudeOpus4_6 import SupremeBattleSnake_ClaudeOpus4_6 as SupremeBattleSnake +from snakes.engine.bitboard import BitBoard from server.GameBoard import GameBoard # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -348,7 +348,7 @@ class TestParityWithApex(unittest.TestCase): def test_trapped_corner(self): """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)], other_bodies=[], foods=[(5, 5)], width=7, height=7) diff --git a/tests/snakes/test_UltimateBattleSnake.py b/tests/snakes/test_UltimateBattleSnake.py index ccf30a4..c06ff2a 100644 --- a/tests/snakes/test_UltimateBattleSnake.py +++ b/tests/snakes/test_UltimateBattleSnake.py @@ -1,6 +1,6 @@ import unittest -from snakes.UltimateBattleSnake import UltimateBattleSnake +from snakes.legacy.UltimateBattleSnake import UltimateBattleSnake from server.GameBoard import GameBoard # ── Helpers ─────────────────────────────────────────────────────────────────── diff --git a/tests/test_BestBattleSnake.py b/tests/test_BestBattleSnake.py index 64864cc..2af2c5a 100644 --- a/tests/test_BestBattleSnake.py +++ b/tests/test_BestBattleSnake.py @@ -1,6 +1,6 @@ import unittest -from snakes.BestBattleSnake import BestBattleSnake +from snakes.legacy.BestBattleSnake import BestBattleSnake from server.GameBoard import GameBoard def make_board(game_state): diff --git a/tests/test_MasterSnake.py b/tests/test_MasterSnake.py index 3347a57..61e408d 100644 --- a/tests/test_MasterSnake.py +++ b/tests/test_MasterSnake.py @@ -1,5 +1,5 @@ import unittest -from snakes.MasterSnake import MasterSnake +from snakes.legacy.MasterSnake import MasterSnake class TestMasterSnake(unittest.TestCase): def setUp(self): diff --git a/tests/tests.py b/tests/tests.py index f926a85..85c0f90 100644 --- a/tests/tests.py +++ b/tests/tests.py @@ -12,7 +12,7 @@ in the folder where this file exists: """ import unittest -from snakes.LogicSnake import avoid_my_neck +from snakes.legacy.LogicSnake import avoid_my_neck class AvoidNeckTest(unittest.TestCase):