feat(snake): modularize engine and add tournament tools

- Split active strategies, reusable engine code, core classes, and legacy snakes.
- Replace implicit snake imports with explicit module registrations.
- Extract Prism duel, spatial, and survival behavior into focused mixins.
- Improve duel scoring with food races, pressure, caches, and depth metrics.
- Add deterministic arena scenarios and paired seeded engine tournaments.
- Expand benchmark telemetry and bump Prism to version 1.3.0.
- Update documentation and tests for the new package layout and tooling.
This commit is contained in:
2026-08-01 20:25:07 +02:00
parent cb6c8d4dc8
commit 3a9af3f54d
39 changed files with 1447 additions and 768 deletions
+6
View File
@@ -0,0 +1,6 @@
"""Actively maintained competitive snake strategies."""
from snakes.strategies.apex import ApexBattleSnake
from snakes.strategies.prism import PrismBattleSnake_GPT_5_6_Sol
__all__ = ("ApexBattleSnake", "PrismBattleSnake_GPT_5_6_Sol")
File diff suppressed because it is too large Load Diff
+169
View File
@@ -0,0 +1,169 @@
"""PrismBattleSnake_GPT_5_6_Sol v1.3.0
Built on ApexBattleSnake v1.0.0. All strategic logic is inherited.
Performance improvement: all spatial primitives (flood fill, territory,
articulation detection, distance maps, pathfinding) replaced by a
bitboard engine that uses integer arithmetic instead of Python sets/deques.
Key speedups:
S1: Bitboard flood fill replaces BFS deque+set with integer bit-expansion.
~60× faster per call, eliminates _neighbors() generator overhead.
S2: Bitboard territory dual-BFS expansion on ints replaces per-cell
distance-map comparison loop.
S3: Bitboard articulation partition sizes via bit-flood instead of
_bounded_bfs with sets.
S4: Bitboard distance map BFS via bit-expansion + bit-extract.
S5: Bitboard path distance early-exit BFS on ints.
S6: Bitboard nearest food BFS food search on ints.
S7: Per-turn BitBoard instance cached for board dimensions.
S8: Blocked-set bitboard conversion cached within a turn to avoid
redundant O(n) conversions for the same frozen set.
S9: Survival-tree uses bitboards natively enemy body/attack bits
precomputed once at tree root, no per-node set/dict rebuilds.
S10: _legal_moves override uses bitboard neighbour mask instead of
per-direction Python loop + _in_bounds calls.
S11: _future_survival_tree inlines legal-move check with bitboard ops.
S12: Duel minimax uses tuple bodies and bitboard move generation.
S13: Iterative deepening reuses a transposition table and move-order hints.
S14: Candidate duel moves and enemy replies resolve on the same root turn.
S15: Candidate moves share one duel transposition/search context per turn.
S16: Compact adversarial multiplayer rollout advances plausible enemy replies.
S17: Rollout memoization and adaptive depth spend time on ambiguous positions.
S18: Prism uses a deeper tactical horizon while retaining Apex's timeout reserve.
"""
from __future__ import annotations
from server.GameBoard import GameBoard
from snakes.engine.bitboard import BitBoard
from snakes.engine.duel import BitboardDuelMixin
from snakes.engine.duel_search import BitboardDuelSearch
from snakes.engine.spatial import BitboardSpatialMixin
from snakes.engine.survival import BitboardSurvivalMixin
from snakes.engine.survival_search import CompactSurvivalSearch
from snakes.strategies.apex import ApexBattleSnake
# Direction offsets for coord-dict → tuple conversion
_DIR_DELTAS = ((0, 1), (0, -1), (-1, 0), (1, 0))
_DIR_NAMES = ("up", "down", "left", "right")
class PrismBattleSnake_GPT_5_6_Sol(
BitboardDuelMixin,
BitboardSurvivalMixin,
BitboardSpatialMixin,
ApexBattleSnake,
):
VERSION = "1.3.0"
def __init__(self) -> None:
super().__init__()
self.name = "PrismBattleSnake"
self.version = self.VERSION
# Prism's compact state search is fast enough to inspect one additional
# turn. The existing deadline checks and Apex timeout reserve still cap the
# work on difficult positions.
self._planning_depth = max(self._planning_depth, 4)
# S7: cached BitBoard instance (reused while board dimensions stay the same)
self._bb: BitBoard | None = None
self._bb_w: int = 0
self._bb_h: int = 0
# S9: precomputed enemy state for survival tree (set per turn in choose_move)
self._enemy_body_bits: int = 0 # all enemy body cells as bitboard
self._enemy_tail_bits: int = 0 # enemy tails that will vacate
self._enemy_attack_danger: int = 0 # tiles where enemy len >= our len
self._enemy_attack_opportunity: int = 0 # tiles where enemy len < our len
# Shared per-turn search contexts. Candidate moves overlap heavily, so
# rebuilding their transposition tables wastes most iterative-deepening work.
self._duel_search_context: BitboardDuelSearch | None = None
self._survival_search_context: CompactSurvivalSearch | None = None
# ── choose_move override: precompute enemy bits ──────────────────────────
def choose_move(self, game_data: GameBoard) -> str:
bb = self._get_bb(game_data.get_width(), game_data.get_height())
self._duel_search_context = None
self._survival_search_context = None
# S9: precompute enemy body / tail / attack bitboards for survival tree
other_snakes = game_data.get_other_snakes()
my_snake = game_data.get_my_snake()
my_len = my_snake.get("length", len(my_snake["body"]))
food_set = {(f["x"], f["y"]) for f in game_data.get_food()}
all_occupied = {
(seg["x"], seg["y"])
for snake in [my_snake, *other_snakes]
for seg in snake["body"]
}
game_type = game_data.get_type()
is_constrictor = game_type == "constrictor"
w = bb.width
enemy_body_bits = 0
enemy_tail_bits = 0
enemy_attack_danger = 0
enemy_attack_opportunity = 0
for snake in other_snakes:
for seg in snake["body"]:
enemy_body_bits |= 1 << (seg["y"] * w + seg["x"])
body = snake["body"]
# Check if tail will vacate
if not is_constrictor and len(body) >= 2:
tail_stacked = (
body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"]
)
if not tail_stacked:
can_grow = self._enemy_can_grow_this_turn(
snake, food_set, all_occupied
)
if not can_grow:
enemy_tail_bits |= 1 << (body[-1]["y"] * w + body[-1]["x"])
# Attack map: tiles enemy head can reach in 1 move
eh = snake["head"]
e_len = snake.get("length", len(body))
ehx, ehy = eh["x"], eh["y"]
for dx, dy in _DIR_DELTAS:
nx, ny = ehx + dx, ehy + dy
if 0 <= nx < w and 0 <= ny < bb.height:
bit = 1 << (ny * w + nx)
if e_len >= my_len:
enemy_attack_danger |= bit
else:
enemy_attack_opportunity |= bit
self._enemy_body_bits = enemy_body_bits
self._enemy_tail_bits = enemy_tail_bits
self._enemy_attack_danger = enemy_attack_danger
self._enemy_attack_opportunity = enemy_attack_opportunity
move = super().choose_move(game_data)
history = self.get_history()
if history:
thinking = history[-1]
if self._duel_search_context is not None:
thinking["prism_duel_depth"] = self._duel_search_context.completed_depth
thinking["prism_duel_nodes"] = self._duel_search_context.nodes
thinking["prism_duel_cache_hits"] = (
self._duel_search_context.cache_hits
+ self._duel_search_context.evaluation_cache_hits
)
thinking["prism_duel_deadline_exits"] = (
self._duel_search_context.deadline_exits
)
if self._survival_search_context is not None:
thinking["prism_rollout_depth"] = (
self._survival_search_context.completed_depth
)
thinking["prism_rollout_nodes"] = self._survival_search_context.nodes
thinking["prism_rollout_cache_hits"] = (
self._survival_search_context.cache_hits
)
thinking["prism_rollout_deadline_exits"] = (
self._survival_search_context.deadline_exits
)
return move