feat(snake): add adaptive adversarial search
- Share duel search contexts and transpositions across candidate moves. - Add aspiration windows, principal variation ordering, and body caches. - Model simultaneous multiplayer responses with a compact beam rollout. - Adapt search depth and response breadth to the remaining deadline. - Add a deterministic arena benchmark with optional JSON reporting. - Expose search metrics, document benchmarking, and bump Prism to 1.2.0.
This commit is contained in:
@@ -0,0 +1,263 @@
|
||||
"""Compact adversarial rollout for multiplayer Battlesnake positions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from itertools import product
|
||||
from time import perf_counter
|
||||
|
||||
from snakes.bitboard import BitBoard
|
||||
|
||||
Body = tuple[int, ...]
|
||||
EnemyBodies = tuple[Body, ...]
|
||||
StateKey = tuple[Body, EnemyBodies, int, int]
|
||||
|
||||
class CompactSurvivalSearch:
|
||||
"""Small paranoid beam search with simultaneous enemy responses.
|
||||
|
||||
It is deliberately narrower than full multiplayer minimax: each enemy keeps
|
||||
only its most dangerous replies and the combined response beam is capped.
|
||||
This models moving opponents without exhausting the request deadline.
|
||||
"""
|
||||
|
||||
DEATH = -5000.0
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
board: BitBoard,
|
||||
food: set[tuple[int, int]],
|
||||
is_constrictor: bool,
|
||||
deadline: float | None,
|
||||
branch: int,
|
||||
enemy_branch: int = 2,
|
||||
response_cap: int = 8,
|
||||
) -> None:
|
||||
self.board = board
|
||||
self.food_bits = board.set_to_bits(food)
|
||||
self.is_constrictor = is_constrictor
|
||||
self.deadline = deadline
|
||||
self.branch = max(1, branch)
|
||||
self.enemy_branch = max(1, enemy_branch)
|
||||
self.response_cap = max(1, response_cap)
|
||||
self.cache: dict[StateKey, float] = {}
|
||||
self.body_bits_cache: dict[Body, int] = {}
|
||||
self.nodes = 0
|
||||
self.cache_hits = 0
|
||||
|
||||
def body_from_dicts(self, body: list[dict]) -> Body:
|
||||
return tuple(self.board.idx(segment["x"], segment["y"]) for segment in body)
|
||||
|
||||
def search_selected(
|
||||
self,
|
||||
my_body: list[dict],
|
||||
enemies: list[dict],
|
||||
target: tuple[int, int],
|
||||
depth: int,
|
||||
) -> float:
|
||||
mine = self.body_from_dicts(my_body)
|
||||
enemy_bodies = tuple(self.body_from_dicts(enemy["body"]) for enemy in enemies)
|
||||
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)
|
||||
|
||||
def _selected_root(
|
||||
self, mine: Body, enemies: EnemyBodies, food_bits: int, target: int, depth: int,
|
||||
) -> float:
|
||||
replies = self._enemy_responses(enemies, mine, target, food_bits)
|
||||
if not replies:
|
||||
replies = [()]
|
||||
worst = float("inf")
|
||||
for response in replies:
|
||||
if self._out_of_time():
|
||||
break
|
||||
child = self._advance(mine, enemies, target, response, food_bits)
|
||||
if child is None:
|
||||
value = self.DEATH
|
||||
else:
|
||||
next_mine, next_enemies, next_food = child
|
||||
value = self._evaluate(next_mine, next_enemies)
|
||||
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
|
||||
|
||||
def _search(self, mine: Body, enemies: EnemyBodies, food_bits: int, depth: int) -> float:
|
||||
self.nodes += 1
|
||||
if self._out_of_time() or depth <= 0:
|
||||
return 0.0
|
||||
key = (mine, enemies, food_bits, depth)
|
||||
cached = self.cache.get(key)
|
||||
if cached is not None:
|
||||
self.cache_hits += 1
|
||||
return cached
|
||||
|
||||
occupied = self._occupied(mine, enemies)
|
||||
targets = list(self._iter_bits(self.board.neighbors_of(mine[0])))
|
||||
ranked: list[tuple[float, int]] = []
|
||||
for target in targets:
|
||||
# Collision legality is finalized simultaneously because eating controls
|
||||
# whether tails vacate.
|
||||
ate = bool((1 << target) & food_bits)
|
||||
own_tail_blocked = self.is_constrictor or ate
|
||||
body_blocked = self._body_bits(mine if own_tail_blocked else mine[:-1])
|
||||
enemy_blocked = 0
|
||||
for enemy in enemies:
|
||||
enemy_blocked |= self._body_bits(enemy[:-1] if not self.is_constrictor else enemy)
|
||||
if (1 << target) & (body_blocked | enemy_blocked):
|
||||
continue
|
||||
free_space = self.board.flood_count(target, occupied & ~(1 << target))
|
||||
ranked.append((free_space + (20 if ate else 0), target))
|
||||
ranked.sort(reverse=True)
|
||||
|
||||
if not ranked:
|
||||
return self.DEATH
|
||||
|
||||
best = self.DEATH
|
||||
for _, target in ranked[:self.branch]:
|
||||
replies = self._enemy_responses(enemies, mine, target, food_bits) or [()]
|
||||
worst = float("inf")
|
||||
for response in replies:
|
||||
if self._out_of_time():
|
||||
break
|
||||
child = self._advance(mine, enemies, target, response, food_bits)
|
||||
if child is None:
|
||||
value = self.DEATH
|
||||
else:
|
||||
next_mine, next_enemies, next_food = child
|
||||
value = self._evaluate(next_mine, next_enemies)
|
||||
if depth > 1 and value > self.DEATH:
|
||||
value += self._search(next_mine, next_enemies, next_food, depth - 1) * 0.72
|
||||
worst = min(worst, value)
|
||||
if worst != float("inf"):
|
||||
best = max(best, worst)
|
||||
|
||||
if not self._out_of_time() and len(self.cache) < 16_384:
|
||||
self.cache[key] = best
|
||||
return best
|
||||
|
||||
def _enemy_responses(
|
||||
self, enemies: EnemyBodies, mine: Body, my_target: int, food_bits: int,
|
||||
) -> list[tuple[int, ...]]:
|
||||
if not enemies:
|
||||
return []
|
||||
choices: list[list[int]] = []
|
||||
my_length_after = len(mine) + int(bool((1 << my_target) & food_bits))
|
||||
for enemy in enemies:
|
||||
ranked: list[tuple[float, int]] = []
|
||||
for target in self._iter_bits(self.board.neighbors_of(enemy[0])):
|
||||
ate = bool((1 << target) & food_bits)
|
||||
enemy_length_after = len(enemy) + int(ate)
|
||||
score = 0.0
|
||||
if target == my_target:
|
||||
score += 1000.0 if enemy_length_after >= my_length_after else -1000.0
|
||||
tx, ty = self.board.coord(target)
|
||||
mx, my = self.board.coord(my_target)
|
||||
score -= abs(tx - mx) + abs(ty - my)
|
||||
score += self.board.open_neighbor_count(target, self._occupied(mine, enemies)) * 3.0
|
||||
score += 20.0 if ate else 0.0
|
||||
ranked.append((score, target))
|
||||
ranked.sort(reverse=True)
|
||||
choices.append([target for _, target in ranked[:self.enemy_branch]])
|
||||
|
||||
responses: list[tuple[int, ...]] = []
|
||||
for response in product(*choices):
|
||||
responses.append(response)
|
||||
if len(responses) >= self.response_cap:
|
||||
break
|
||||
return responses
|
||||
|
||||
def _advance(
|
||||
self,
|
||||
mine: Body,
|
||||
enemies: EnemyBodies,
|
||||
my_target: int,
|
||||
enemy_targets: tuple[int, ...],
|
||||
food_bits: int,
|
||||
) -> tuple[Body, EnemyBodies, int] | None:
|
||||
my_ate = bool((1 << my_target) & food_bits)
|
||||
next_mine = self._advance_body(mine, my_target, my_ate)
|
||||
next_enemies = tuple(
|
||||
self._advance_body(body, target, bool((1 << target) & food_bits))
|
||||
for body, target in zip(enemies, enemy_targets)
|
||||
)
|
||||
|
||||
# Body and self collisions after all tails have moved.
|
||||
if my_target in next_mine[1:]:
|
||||
return None
|
||||
if any(my_target in enemy[1:] for enemy in next_enemies):
|
||||
return None
|
||||
|
||||
surviving: list[Body] = []
|
||||
for index, enemy in enumerate(next_enemies):
|
||||
target = enemy[0]
|
||||
dead = target in enemy[1:] or target in next_mine[1:]
|
||||
dead = dead or any(
|
||||
target in other[1:] for other_index, other in enumerate(next_enemies)
|
||||
if other_index != index
|
||||
)
|
||||
if target == my_target:
|
||||
if len(enemy) >= len(next_mine):
|
||||
return None
|
||||
dead = True
|
||||
if not dead:
|
||||
# Enemy/enemy head collisions remove equal-length snakes and the shorter.
|
||||
for other_index, other in enumerate(next_enemies):
|
||||
if other_index != index and target == other[0] and len(enemy) <= len(other):
|
||||
dead = True
|
||||
break
|
||||
if not dead:
|
||||
surviving.append(enemy)
|
||||
|
||||
eaten = (1 << my_target) if my_ate else 0
|
||||
for body, target in zip(enemies, enemy_targets):
|
||||
if (1 << target) & food_bits:
|
||||
eaten |= 1 << target
|
||||
return next_mine, tuple(surviving), food_bits & ~eaten
|
||||
|
||||
def _evaluate(self, mine: Body, enemies: EnemyBodies) -> float:
|
||||
blocked = self._occupied(mine, enemies) & ~(1 << mine[0])
|
||||
space = self.board.flood_count(mine[0], blocked)
|
||||
liberties = self.board.open_neighbor_count(mine[0], blocked)
|
||||
if liberties == 0 or space < len(mine):
|
||||
return self.DEATH
|
||||
enemy_pressure = 0.0
|
||||
for enemy in enemies:
|
||||
enemy_blocked = blocked & ~(1 << enemy[0])
|
||||
enemy_space = self.board.flood_count(enemy[0], enemy_blocked)
|
||||
enemy_liberties = self.board.open_neighbor_count(enemy[0], enemy_blocked)
|
||||
enemy_pressure += max(0, 3 - enemy_liberties) * 18.0
|
||||
if len(mine) > len(enemy):
|
||||
enemy_pressure += max(0, 8 - enemy_space) * 8.0
|
||||
return space * 1.9 + liberties * 32.0 + enemy_pressure - len(enemies) * 4.0
|
||||
|
||||
def _occupied(self, mine: Body, enemies: EnemyBodies) -> int:
|
||||
occupied = self._body_bits(mine)
|
||||
for enemy in enemies:
|
||||
occupied |= self._body_bits(enemy)
|
||||
return occupied
|
||||
|
||||
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
|
||||
|
||||
def _advance_body(self, body: Body, target: int, ate: bool) -> Body:
|
||||
if self.is_constrictor or ate:
|
||||
return (target,) + body
|
||||
return (target,) + body[:-1]
|
||||
|
||||
@staticmethod
|
||||
def _iter_bits(bits: int):
|
||||
while bits:
|
||||
bit = bits & -bits
|
||||
yield bit.bit_length() - 1
|
||||
bits ^= bit
|
||||
|
||||
def _out_of_time(self) -> bool:
|
||||
return self.deadline is not None and perf_counter() >= self.deadline
|
||||
Reference in New Issue
Block a user