Files
snake-python/snakes/bitboard_duel_search.py
T
daniel156161 c646392b84 fix: preserve gameplay data and correct duel evaluation
- Preserve snake customizations across database migrations and merges.
- Lazily load optional storage backends for SQLite maintenance scripts.
- Match Apex territory and nearest-food tie-breaking semantics.
- Resolve duel occupancy after simultaneous movement and food growth.
- Recompute simulated head-to-head danger after body growth.
- Add regression coverage and declare the aiofiles dependency.
2026-08-01 18:16:04 +02:00

291 lines
9.7 KiB
Python

"""Deadline-aware simultaneous duel search using compact tuple bodies and bitboards."""
from __future__ import annotations
from dataclasses import dataclass
from time import perf_counter
from typing import Iterable
from snakes.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]] = {}
self.killer_moves: dict[int, int] = {}
self.history: dict[int, int] = {}
self.nodes = 0
self.cache_hits = 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
value, completed = self._search(state, depth, -float("inf"), float("inf"))
if not completed:
break
result = value
completed_depth = 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, _ = self._search(state, depth, -float("inf"), float("inf"))
return value
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 = 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)
enemy_moves = self._ordered_moves(enemy_moves, state, depth, False)
best = -float("inf")
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
best = max(best, worst)
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)
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) -> 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))
killer_bonus = 10_000.0 if target == killer else 0.0
return 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:
my_blocked = self._body_bits(state.my_body[1:]) | self._body_bits(state.enemy_body[1:])
my_space = self.board.flood_count(state.my_body[0], my_blocked)
enemy_space = self.board.flood_count(state.enemy_body[0], my_blocked)
my_liberties = self.board.open_neighbor_count(state.my_body[0], my_blocked)
enemy_liberties = self.board.open_neighbor_count(state.enemy_body[0], my_blocked)
length_score = (len(state.my_body) - len(state.enemy_body)) * 18.0
health_score = (state.my_health - state.enemy_health) * 0.15
return (my_space - enemy_space) * 2.0 + (my_liberties - enemy_liberties) * 12.0 + length_score + health_score
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]
@staticmethod
def _body_bits(body: Body) -> int:
bits = 0
for cell in body:
bits |= 1 << cell
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
return perf_counter() + reserve_ms / 1000.0 >= self.deadline