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
+453
View File
@@ -0,0 +1,453 @@
"""Deadline-aware simultaneous duel search using compact tuple bodies and bitboards."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from time import perf_counter
from snakes.engine.bitboard import BitBoard
Body = tuple[int, ...]
@dataclass(frozen=True, slots=True)
class DuelState:
my_body: Body
enemy_body: Body
food_bits: int
my_health: int
enemy_health: int
previous_hazard_bits: int
class BitboardDuelSearch:
"""Iterative-deepening paranoid minimax for a two-snake game.
The public API still accepts Battlesnake body dictionaries. Search nodes use
flat cell indices, immutable tuples, and integer masks to avoid allocation of
coordinate dictionaries and sets in the hot path.
"""
WIN = 100_000.0
LOSS = -100_000.0
def __init__(
self,
board: BitBoard,
food: Iterable[tuple[int, int]],
hazards: Iterable[tuple[int, int]],
hazard_count: dict[tuple[int, int], int],
hazard_damage: int,
deadline: float | None,
) -> None:
self.board = board
self.deadline = deadline
self.hazard_damage = hazard_damage
self.food_bits = board.set_to_bits(set(food))
self.hazard_bits = board.set_to_bits(set(hazards))
self.hazard_stacks = {
board.idx(x, y): count for (x, y), count in hazard_count.items()
}
self.transposition: dict[tuple[DuelState, int], tuple[float, str, int | None]] = {}
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)
def search(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
max_depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> tuple[float, int]:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
result = self._evaluate(state)
completed_depth = 0
for depth in range(1, max_depth + 1):
if self._out_of_time(5.0):
break
window = 80.0 if completed_depth else float("inf")
alpha, beta = result - window, result + window
value, completed = self._search(state, depth, alpha, beta)
if completed and window != float("inf") and (value <= alpha or value >= beta):
value, completed = self._search(state, depth, -float("inf"), float("inf"))
if not completed:
break
result = value
completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth
def search_candidate(
self,
my_body: list[dict],
enemy_body: list[dict],
my_target: tuple[int, int],
my_health: int,
enemy_health: int,
max_depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> tuple[float, int]:
"""Evaluate one selected move against every simultaneous enemy reply.
``max_depth`` counts the selected root turn, so a completed depth of one
means all opponent replies to that move were resolved.
"""
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
target_idx = self.board.idx(my_target[0], my_target[1])
if not self.board.neighbors_of(state.my_body[0]) & (1 << target_idx):
return self.LOSS, 0
result = self._evaluate(state)
completed_depth = 0
for depth in range(1, max_depth + 1):
if self._out_of_time(5.0):
break
window = 80.0 if completed_depth else float("inf")
alpha, beta = result - window, result + window
value, completed = self._search_selected_move(state, target_idx, depth, alpha, beta)
if completed and window != float("inf") and (value <= alpha or value >= beta):
value, completed = self._search_selected_move(
state, target_idx, depth, -float("inf"), float("inf")
)
if not completed:
break
result = value
completed_depth = depth
self.completed_depth = max(self.completed_depth, completed_depth)
return result, completed_depth
def search_depth(
self,
my_body: list[dict],
enemy_body: list[dict],
my_health: int,
enemy_health: int,
depth: int,
previous_hazards: Iterable[tuple[int, int]],
) -> float:
state = DuelState(
my_body=self.body_from_dicts(my_body),
enemy_body=self.body_from_dicts(enemy_body),
food_bits=self.food_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.board.set_to_bits(set(previous_hazards)),
)
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(
self,
state: DuelState,
my_target: int,
depth: int,
alpha: float,
beta: float,
) -> tuple[float, bool]:
"""Resolve the selected root move with the opponent on the same turn."""
self.nodes += 1
if self._out_of_time():
return self._evaluate(state), False
enemy_moves = self._candidate_targets(state.enemy_body)
if not enemy_moves:
return self.WIN + depth, True
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
worst = float("inf")
for enemy_target in enemy_moves:
if self._out_of_time():
return (worst if worst != float("inf") else self._evaluate(state)), False
child, terminal = self._advance(state, my_target, enemy_target)
if terminal is not None:
value = terminal
completed = True
elif depth <= 1:
value = self._evaluate(child)
completed = True
else:
value, completed = self._search(child, depth - 1, alpha, beta)
if not completed:
return (worst if worst != float("inf") else value), False
worst = min(worst, value)
beta = min(beta, worst)
if beta <= alpha:
break
return worst, True
def _search(self, state: DuelState, depth: int, alpha: float, beta: float) -> tuple[float, bool]:
self.nodes += 1
if self._out_of_time():
return self._evaluate(state), False
if depth <= 0:
return self._evaluate(state), True
cache_key = (state, depth)
original_alpha, original_beta = alpha, beta
cached = self.transposition.get(cache_key)
if cached is not None:
self.cache_hits += 1
cached_value, bound, preferred_move = cached
if bound == "exact":
return cached_value, True
if bound == "lower":
alpha = max(alpha, cached_value)
else:
beta = min(beta, cached_value)
if alpha >= beta:
return cached_value, True
my_moves = self._candidate_targets(state.my_body)
enemy_moves = self._candidate_targets(state.enemy_body)
if not my_moves:
return self.LOSS - depth, True
if not enemy_moves:
return self.WIN + depth, True
my_moves = self._ordered_moves(my_moves, state, depth, True, preferred_move if cached is not None else None)
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
best = -float("inf")
best_move: int | None = None
for my_target in my_moves:
worst = float("inf")
for enemy_target in enemy_moves:
if self._out_of_time():
return (best if best != -float("inf") else self._evaluate(state)), False
child, terminal = self._advance(state, my_target, enemy_target)
if terminal is not None:
value = terminal
completed = True
else:
value, completed = self._search(child, depth - 1, alpha, beta)
if not completed:
return (best if best != -float("inf") else value), False
worst = min(worst, value)
if worst <= alpha:
self.killer_moves[depth] = my_target
self.history[my_target] = self.history.get(my_target, 0) + depth * depth
break
if worst > best:
best = worst
best_move = my_target
alpha = max(alpha, best)
if alpha >= beta:
break
if best <= original_alpha:
bound = "upper"
elif best >= original_beta:
bound = "lower"
else:
bound = "exact"
self.transposition[cache_key] = (best, bound, best_move)
return best, True
def _advance(self, state: DuelState, my_target: int, enemy_target: int) -> tuple[DuelState, float | None]:
my_ate = bool((1 << my_target) & state.food_bits)
enemy_ate = bool((1 << enemy_target) & state.food_bits)
my_body = self._advance_body(state.my_body, my_target, my_ate)
enemy_body = self._advance_body(state.enemy_body, enemy_target, enemy_ate)
my_dead = my_target in my_body[1:] or my_target in enemy_body[1:]
enemy_dead = enemy_target in enemy_body[1:] or enemy_target in my_body[1:]
if my_target == enemy_target:
if len(my_body) <= len(enemy_body):
my_dead = True
if len(enemy_body) <= len(my_body):
enemy_dead = True
my_health = 100 if my_ate else state.my_health - 1
enemy_health = 100 if enemy_ate else state.enemy_health - 1
if not my_ate:
my_health -= self._hazard_cost(my_target, state.previous_hazard_bits)
if not enemy_ate:
enemy_health -= self._hazard_cost(enemy_target, state.previous_hazard_bits)
my_dead = my_dead or my_health <= 0
enemy_dead = enemy_dead or enemy_health <= 0
if my_dead and enemy_dead:
return state, -500.0
if my_dead:
return state, self.LOSS
if enemy_dead:
return state, self.WIN
eaten_bits = 0
if my_ate:
eaten_bits |= 1 << my_target
if enemy_ate:
eaten_bits |= 1 << enemy_target
child = DuelState(
my_body=my_body,
enemy_body=enemy_body,
food_bits=state.food_bits & ~eaten_bits,
my_health=my_health,
enemy_health=enemy_health,
previous_hazard_bits=self.hazard_bits,
)
return child, None
def _candidate_targets(self, body: Body) -> list[int]:
"""Return in-bounds targets; `_advance` resolves simultaneous collisions.
Delaying occupancy checks until both targets and food growth are known is
essential: whether either tail vacates depends on that snake eating.
"""
return list(self._iter_bits(self.board.neighbors_of(body[0])))
def _ordered_moves(
self, moves: list[int], state: DuelState, depth: int, mine: bool,
preferred: int | None = None,
) -> list[int]:
body = state.my_body if mine else state.enemy_body
other = state.enemy_body if mine else state.my_body
killer = self.killer_moves.get(depth)
center_x = (self.board.width - 1) / 2.0
center_y = (self.board.height - 1) / 2.0
def score(target: int) -> tuple[float, int]:
x, y = self.board.coord(target)
food_bonus = 200.0 if (1 << target) & state.food_bits else 0.0
space = self.board.flood_count(target, (self._body_bits(body[1:]) | self._body_bits(other[1:])) & ~(1 << target))
center = -(abs(x - center_x) + abs(y - center_y))
preferred_bonus = 20_000.0 if target == preferred else 0.0
killer_bonus = 10_000.0 if target == killer else 0.0
return preferred_bonus + killer_bonus + self.history.get(target, 0) + food_bonus + space * 2.0 + center, -target
# Our strongest-looking moves first; enemy ordering uses the same quality
# estimate because dangerous enemy replies tend to gain space and food.
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)
enemy_space = self.board.flood_count(enemy_head, my_blocked)
my_liberties = self.board.open_neighbor_count(my_head, my_blocked)
enemy_liberties = self.board.open_neighbor_count(enemy_head, my_blocked)
territory = self.board.territory(my_head, [enemy_head], my_blocked)
my_tail_path = self.board.path_distance(my_head, state.my_body[-1], my_blocked)
enemy_tail_path = self.board.path_distance(enemy_head, state.enemy_body[-1], my_blocked)
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_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
# 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 + 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
if not (bit & self.hazard_bits & previous_hazard_bits):
return 0
return self.hazard_damage * self.hazard_stacks.get(target, 1)
@staticmethod
def _advance_body(body: Body, target: int, ate: bool) -> Body:
return (target,) + body if ate else (target,) + body[:-1]
@staticmethod
def _tail_stacked(body: Body) -> bool:
return len(body) >= 2 and body[-1] == body[-2]
def _body_bits(self, body: Body) -> int:
cached = self._body_bits_cache.get(body)
if cached is not None:
return cached
bits = 0
for cell in body:
bits |= 1 << cell
if len(self._body_bits_cache) < 16_384:
self._body_bits_cache[body] = bits
return bits
@staticmethod
def _iter_bits(bits: int):
while bits:
bit = bits & -bits
yield bit.bit_length() - 1
bits ^= bit
def _out_of_time(self, reserve_ms: float = 0.0) -> bool:
if self.deadline is None:
return False
expired = perf_counter() + reserve_ms / 1000.0 >= self.deadline
if expired:
self.deadline_exits += 1
return expired