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:
@@ -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
|
||||
Reference in New Issue
Block a user