970 lines
32 KiB
Python
970 lines
32 KiB
Python
from collections.abc import Iterator
|
|
from collections import deque
|
|
from typing import Any, cast
|
|
import random, os
|
|
|
|
from snakes.TemplateSnake import TemplateSnake
|
|
|
|
class BestBattleSnake(TemplateSnake):
|
|
VERSION = "2.5.0"
|
|
Point = tuple[int, int]
|
|
Coord = dict[str, int]
|
|
SnakeState = dict[str, Any]
|
|
MoveMap = dict[str, Coord]
|
|
AttackMap = dict[Point, int]
|
|
|
|
DIRECTIONS = {
|
|
"up": (0, 1),
|
|
"down": (0, -1),
|
|
"left": (-1, 0),
|
|
"right": (1, 0),
|
|
}
|
|
|
|
OPPOSITE = {
|
|
"up": "down",
|
|
"down": "up",
|
|
"left": "right",
|
|
"right": "left",
|
|
}
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.name = "BestBattleSnake"
|
|
self.version = self.VERSION
|
|
self.recent_heads = deque(maxlen=14)
|
|
self.last_move = None
|
|
self.last_game_id = None
|
|
self.previous_hazards = set()
|
|
self.duel_style = self._get_duel_style()
|
|
|
|
def _get_duel_style(self) -> str:
|
|
"""Resolve duel tuning style from `BATTLE_SNAKE_DUEL_STYLE` or `DUEL_STYLE`."""
|
|
value = os.getenv("BATTLE_SNAKE_DUEL_STYLE")
|
|
if value is None:
|
|
value = os.getenv("DUEL_STYLE", "balanced")
|
|
|
|
style = value.strip().lower()
|
|
if style not in {"safe", "balanced", "aggressive"}:
|
|
return "balanced"
|
|
return style
|
|
|
|
def _duel_weights(self, style:str) -> dict[str, float]:
|
|
"""Return score multipliers for the selected duel style preset."""
|
|
if style == "safe":
|
|
return {
|
|
"head_pressure": 0.65,
|
|
"distance_safety": 1.30,
|
|
"food_bias": 1.00,
|
|
}
|
|
if style == "aggressive":
|
|
return {
|
|
"head_pressure": 1.35,
|
|
"distance_safety": 0.75,
|
|
"food_bias": 0.85,
|
|
}
|
|
return {
|
|
"head_pressure": 1.00,
|
|
"distance_safety": 1.00,
|
|
"food_bias": 1.00,
|
|
}
|
|
|
|
def choose_move(self, game_data:dict) -> str:
|
|
"""Pick the next move from a Battlesnake move request.
|
|
|
|
Docs: https://docs.battlesnake.com/api/example-move
|
|
"""
|
|
self.game_board = game_data
|
|
self.calculations = []
|
|
self.duel_style = self._get_duel_style()
|
|
|
|
game_id = getattr(game_data, "id", None)
|
|
turn = game_data.get_turn()
|
|
if game_id != self.last_game_id or turn <= 1:
|
|
self.recent_heads.clear()
|
|
self.last_move = None
|
|
self.previous_hazards = set()
|
|
self.last_game_id = game_id
|
|
|
|
my_snake = cast(dict[str, Any], game_data.get_my_snake())
|
|
my_head = my_snake["head"]
|
|
my_body = my_snake["body"]
|
|
my_len = my_snake.get("length", len(my_body))
|
|
my_health = my_snake.get("health", 100)
|
|
|
|
width = game_data.get_width()
|
|
height = game_data.get_height()
|
|
board_area = max(1, width * height)
|
|
occupancy_ratio = my_len / board_area
|
|
preserve_space_mode = occupancy_ratio >= 0.34 and my_health > 35
|
|
foods = game_data.get_food()
|
|
hazards = game_data.get_hazard()
|
|
other_snakes = game_data.get_other_snakes()
|
|
is_constrictor = game_data.get_type() == "constrictor"
|
|
|
|
food_set = {(food["x"], food["y"]) for food in foods}
|
|
hazard_set = {(hazard["x"], hazard["y"]) for hazard in hazards}
|
|
previous_hazard_set = set(self.previous_hazards)
|
|
hazard_damage = self._hazard_damage_per_turn(game_data)
|
|
enemy_heads = [
|
|
(snake["head"]["x"], snake["head"]["y"]) for snake in other_snakes
|
|
]
|
|
enemy_can_grow_cache = {
|
|
snake["id"]: self._enemy_can_grow_this_turn(snake, food_set)
|
|
for snake in other_snakes
|
|
if "id" in snake
|
|
}
|
|
current_head_point = (my_head["x"], my_head["y"])
|
|
|
|
safe_moves = self._legal_moves(
|
|
my_head=my_head,
|
|
my_body=my_body,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
is_constrictor=is_constrictor,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
if not safe_moves:
|
|
fallback = self._fallback_move(my_head, width, height)
|
|
self.recent_heads.append(current_head_point)
|
|
self.last_move = fallback
|
|
self.add_to_history(
|
|
{
|
|
"turn": turn,
|
|
"move": fallback,
|
|
"reason": "no_safe_moves",
|
|
}
|
|
)
|
|
self.previous_hazards = set(hazard_set)
|
|
return fallback
|
|
|
|
enemy_attack_map = self._build_enemy_attack_map(
|
|
my_snake=my_snake,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
is_constrictor=is_constrictor,
|
|
width=width,
|
|
height=height,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
)
|
|
|
|
if is_constrictor:
|
|
best_move, scores = self._choose_constrictor_move(
|
|
safe_moves=safe_moves,
|
|
my_body=my_body,
|
|
my_len=my_len,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
enemy_attack_map=enemy_attack_map,
|
|
enemy_heads=enemy_heads,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
self.recent_heads.append(current_head_point)
|
|
self.last_move = best_move
|
|
self.add_to_history({"turn": turn, "move": best_move, "scores": scores})
|
|
self.previous_hazards = set(hazard_set)
|
|
return best_move
|
|
|
|
if len(other_snakes) == 1:
|
|
best_move, scores = self._choose_duel_move(
|
|
safe_moves=safe_moves,
|
|
my_body=my_body,
|
|
my_len=my_len,
|
|
my_health=my_health,
|
|
food_set=food_set,
|
|
hazard_set=hazard_set,
|
|
other_snakes=other_snakes,
|
|
enemy_attack_map=enemy_attack_map,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
previous_hazard_set=previous_hazard_set,
|
|
hazard_damage=hazard_damage,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
self.recent_heads.append(current_head_point)
|
|
self.last_move = best_move
|
|
self.add_to_history({"turn": turn, "move": best_move, "scores": scores})
|
|
self.previous_hazards = set(hazard_set)
|
|
return best_move
|
|
|
|
scores:dict[str, float] = {}
|
|
move_safety:dict[str, dict[str, Any]] = {}
|
|
for move, pos in safe_moves.items():
|
|
point = (pos["x"], pos["y"])
|
|
ate_food = point in food_set
|
|
|
|
future_body = self._future_body(my_body, pos, ate_food, is_constrictor)
|
|
blocked = self._simulation_blocked(
|
|
future_body=future_body,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
is_constrictor=is_constrictor,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
)
|
|
blocked.discard(point)
|
|
|
|
reachable_space = self._flood_fill_count(point, blocked, width, height)
|
|
required_space = len(future_body)
|
|
liberties = self._open_neighbor_count(point, blocked, width, height)
|
|
next_options = self._next_turn_option_count(
|
|
future_body, blocked, width, height
|
|
)
|
|
territory = self._territory_control_score(
|
|
my_start=point,
|
|
enemy_starts=enemy_heads,
|
|
blocked=blocked,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
nearest_food_dist = self._nearest_food_distance(point, food_set, blocked, width, height)
|
|
future_tail = future_body[-1]
|
|
tail_point = (future_tail["x"], future_tail["y"])
|
|
tail_dist = self._path_distance(
|
|
point, tail_point, blocked - {tail_point}, width, height
|
|
)
|
|
has_tail_escape = tail_dist is not None
|
|
likely_dead_end = (
|
|
(reachable_space < required_space and not has_tail_escape)
|
|
or (liberties == 0 and not has_tail_escape)
|
|
or (next_options == 0 and not has_tail_escape)
|
|
)
|
|
|
|
score = 0.0
|
|
score += reachable_space * 2.6
|
|
score += liberties * 18.0
|
|
score += next_options * 10.0
|
|
score += territory * 0.35
|
|
|
|
if reachable_space < required_space:
|
|
score -= 1200.0
|
|
if liberties == 0:
|
|
score -= 900.0
|
|
|
|
enemy_len = enemy_attack_map.get(point)
|
|
if enemy_len is not None:
|
|
if enemy_len >= my_len:
|
|
score -= 1200.0
|
|
else:
|
|
score += 70.0
|
|
|
|
hunger_urgency = max(0.0, (60.0 - my_health) / 60.0)
|
|
if nearest_food_dist is not None:
|
|
score += (28.0 + 70.0 * hunger_urgency) / (nearest_food_dist + 1)
|
|
elif my_health < 30:
|
|
score -= 150.0
|
|
|
|
if ate_food:
|
|
if likely_dead_end:
|
|
score -= 1800.0
|
|
else:
|
|
score += 260.0 + 220.0 * hunger_urgency
|
|
|
|
if preserve_space_mode and ate_food and my_health > 45:
|
|
score -= 280.0
|
|
|
|
if tail_dist is not None:
|
|
score += 12.0 / (tail_dist + 1)
|
|
else:
|
|
score -= 40.0
|
|
|
|
if point in hazard_set:
|
|
hazard_scale = max(0.5, hazard_damage / 14.0)
|
|
if not ate_food:
|
|
score -= (70.0 if my_health > 35 else 250.0) * hazard_scale
|
|
|
|
score -= self._revisit_penalty(point)
|
|
|
|
if self.last_move == move:
|
|
score += 6.0
|
|
elif (
|
|
self.last_move
|
|
and self.OPPOSITE[self.last_move] == move
|
|
and len(safe_moves) > 1
|
|
):
|
|
score -= 20.0
|
|
|
|
health_after_move = 100 if ate_food else my_health - 1
|
|
hazard_active = self._hazard_is_active(point, ate_food, hazard_set, previous_hazard_set)
|
|
if hazard_active:
|
|
health_after_move -= hazard_damage
|
|
if health_after_move <= 0:
|
|
score -= 10000.0
|
|
|
|
is_losing_head_to_head = enemy_len is not None and enemy_len >= my_len
|
|
is_dead_end = likely_dead_end
|
|
move_safety[move] = {
|
|
"is_survivable": (not is_dead_end)
|
|
and (not is_losing_head_to_head)
|
|
and health_after_move > 0,
|
|
"reachable_space": reachable_space,
|
|
"next_options": next_options,
|
|
"tail_escape": has_tail_escape,
|
|
}
|
|
|
|
scores[move] = round(score, 5)
|
|
|
|
survivable_moves = [
|
|
move for move, data in move_safety.items() if data["is_survivable"]
|
|
]
|
|
if survivable_moves:
|
|
best_space = max(
|
|
move_safety[move]["reachable_space"] for move in survivable_moves
|
|
)
|
|
roomy_moves = [
|
|
move
|
|
for move in survivable_moves
|
|
if move_safety[move]["reachable_space"]
|
|
>= max(1, int(best_space * 0.60))
|
|
]
|
|
tail_escape_moves = [
|
|
move for move in survivable_moves if move_safety[move]["tail_escape"]
|
|
]
|
|
if tail_escape_moves:
|
|
considered_moves = tail_escape_moves
|
|
else:
|
|
considered_moves = roomy_moves if roomy_moves else survivable_moves
|
|
else:
|
|
considered_moves = list(scores.keys())
|
|
|
|
best_score = max(scores[move] for move in considered_moves)
|
|
top_moves = [
|
|
move for move in considered_moves if best_score - scores[move] <= 1.5
|
|
]
|
|
best_move = random.choice(top_moves)
|
|
self.recent_heads.append(current_head_point)
|
|
self.last_move = best_move
|
|
self.add_to_history({"turn": turn, "move": best_move, "scores": scores})
|
|
self.previous_hazards = set(hazard_set)
|
|
return best_move
|
|
|
|
def _choose_duel_move(self, safe_moves:MoveMap, my_body:list[Coord], my_len:int, my_health:int, food_set:set[Point], hazard_set:set[Point], other_snakes:list[SnakeState],enemy_attack_map:AttackMap, enemy_can_grow_cache:dict[Any, bool], previous_hazard_set:set[Point], hazard_damage:int, width:int, height:int) -> tuple[str, dict[str, float]]:
|
|
"""Score and select a move for one-vs-one games."""
|
|
duel_weights = self._duel_weights(self.duel_style)
|
|
enemy = other_snakes[0]
|
|
enemy_head = (enemy["head"]["x"], enemy["head"]["y"])
|
|
enemy_len = enemy.get("length", len(enemy["body"]))
|
|
encase_target_space = max(8, enemy_len * 2)
|
|
can_head_hunt = my_len > enemy_len and my_len >= 8
|
|
|
|
scores:dict[str, float] = {}
|
|
move_safety:dict[str, dict[str, Any]] = {}
|
|
|
|
for move, pos in safe_moves.items():
|
|
point = (pos["x"], pos["y"])
|
|
ate_food = point in food_set
|
|
|
|
future_body = self._future_body(
|
|
my_body, pos, ate_food, is_constrictor=False
|
|
)
|
|
blocked = self._simulation_blocked(
|
|
future_body=future_body,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
is_constrictor=False,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
)
|
|
blocked.discard(point)
|
|
|
|
reachable_space = self._flood_fill_count(point, blocked, width, height)
|
|
required_space = len(future_body)
|
|
liberties = self._open_neighbor_count(point, blocked, width, height)
|
|
next_options = self._next_turn_option_count(
|
|
future_body, blocked, width, height
|
|
)
|
|
|
|
nearest_food_dist = self._nearest_food_distance(point, food_set, blocked, width, height)
|
|
future_tail = future_body[-1]
|
|
tail_point = (future_tail["x"], future_tail["y"])
|
|
tail_dist = self._path_distance(
|
|
point, tail_point, blocked - {tail_point}, width, height
|
|
)
|
|
territory = self._territory_control_score(
|
|
my_start=point,
|
|
enemy_starts=[enemy_head],
|
|
blocked=blocked,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
has_tail_escape = tail_dist is not None
|
|
likely_dead_end = (
|
|
(reachable_space < required_space and not has_tail_escape)
|
|
or (liberties == 0 and not has_tail_escape)
|
|
or (next_options == 0 and not has_tail_escape)
|
|
)
|
|
|
|
enemy_attack_len = enemy_attack_map.get(point)
|
|
losing_head_to_head = (
|
|
enemy_attack_len is not None and enemy_attack_len >= my_len
|
|
)
|
|
direct_head_distance = self._manhattan(point, enemy_head)
|
|
enemy_space, enemy_options = self._enemy_confinement_metrics(enemy_head, blocked, width, height)
|
|
|
|
score = 0.0
|
|
score += reachable_space * 2.8
|
|
score += liberties * 18.0
|
|
score += next_options * 10.0
|
|
score += territory * 0.50
|
|
|
|
if likely_dead_end:
|
|
score -= 1400.0
|
|
|
|
if losing_head_to_head:
|
|
score -= 1500.0
|
|
|
|
is_safe_tightening_move = not likely_dead_end and not losing_head_to_head
|
|
if is_safe_tightening_move and enemy_space <= encase_target_space:
|
|
score += (encase_target_space - enemy_space) * 42.0
|
|
score += max(0, 3 - enemy_options) * 95.0
|
|
if reachable_space > enemy_space:
|
|
score += 120.0
|
|
if direct_head_distance <= 2 and can_head_hunt:
|
|
score += 40.0
|
|
|
|
if can_head_hunt:
|
|
if direct_head_distance == 1:
|
|
score += 220.0 * duel_weights["head_pressure"]
|
|
elif direct_head_distance == 2:
|
|
score += 80.0 * duel_weights["head_pressure"]
|
|
else:
|
|
if direct_head_distance <= 2:
|
|
score -= 120.0 * duel_weights["distance_safety"]
|
|
if direct_head_distance == 1:
|
|
score -= 180.0 * duel_weights["distance_safety"]
|
|
|
|
hunger_urgency = max(0.0, (65.0 - my_health) / 65.0)
|
|
if nearest_food_dist is not None:
|
|
score += (
|
|
(25.0 + 90.0 * hunger_urgency) * duel_weights["food_bias"]
|
|
) / (nearest_food_dist + 1)
|
|
|
|
if ate_food:
|
|
if likely_dead_end:
|
|
score -= 1700.0
|
|
else:
|
|
score += 260.0 + 250.0 * hunger_urgency
|
|
|
|
if tail_dist is not None:
|
|
score += 14.0 / (tail_dist + 1)
|
|
else:
|
|
score -= 50.0
|
|
|
|
if point in hazard_set:
|
|
hazard_scale = max(0.5, hazard_damage / 14.0)
|
|
if not ate_food:
|
|
score -= (70.0 if my_health > 35 else 250.0) * hazard_scale
|
|
|
|
score -= self._revisit_penalty(point)
|
|
|
|
if self.last_move == move:
|
|
score += 6.0
|
|
elif (
|
|
self.last_move
|
|
and self.OPPOSITE[self.last_move] == move
|
|
and len(safe_moves) > 1
|
|
):
|
|
score -= 20.0
|
|
|
|
health_after_move = 100 if ate_food else my_health - 1
|
|
hazard_active = self._hazard_is_active(point, ate_food, hazard_set, previous_hazard_set)
|
|
if hazard_active:
|
|
health_after_move -= hazard_damage
|
|
if health_after_move <= 0:
|
|
score -= 10000.0
|
|
|
|
move_safety[move] = {
|
|
"is_survivable": (not likely_dead_end)
|
|
and (not losing_head_to_head)
|
|
and health_after_move > 0,
|
|
"reachable_space": reachable_space,
|
|
"tail_escape": has_tail_escape,
|
|
}
|
|
scores[move] = round(score, 5)
|
|
|
|
survivable_moves = [
|
|
move for move, data in move_safety.items() if data["is_survivable"]
|
|
]
|
|
if survivable_moves:
|
|
tail_escape_moves = [
|
|
move for move in survivable_moves if move_safety[move]["tail_escape"]
|
|
]
|
|
if tail_escape_moves:
|
|
considered_moves = tail_escape_moves
|
|
else:
|
|
best_space = max(
|
|
move_safety[move]["reachable_space"] for move in survivable_moves
|
|
)
|
|
considered_moves = [
|
|
move
|
|
for move in survivable_moves
|
|
if move_safety[move]["reachable_space"]
|
|
>= max(1, int(best_space * 0.60))
|
|
]
|
|
if not considered_moves:
|
|
considered_moves = survivable_moves
|
|
else:
|
|
considered_moves = list(scores.keys())
|
|
|
|
best_score = max(scores[move] for move in considered_moves)
|
|
top_moves = [
|
|
move for move in considered_moves if best_score - scores[move] <= 1.5
|
|
]
|
|
return random.choice(top_moves), scores
|
|
|
|
def _choose_constrictor_move(self, safe_moves:MoveMap, my_body:list[Coord], my_len:int, other_snakes:list[SnakeState], food_set:set[Point], enemy_attack_map:AttackMap, enemy_heads:list[Point], enemy_can_grow_cache:dict[Any, bool], width:int, height:int) -> tuple[str, dict[str, float]]:
|
|
"""Score and select a move for constrictor games."""
|
|
scores:dict[str, float] = {}
|
|
move_safety:dict[str, dict[str, Any]] = {}
|
|
|
|
for move, pos in safe_moves.items():
|
|
point = (pos["x"], pos["y"])
|
|
future_body = self._future_body(
|
|
my_body, pos, ate_food=False, is_constrictor=True
|
|
)
|
|
blocked = self._simulation_blocked(
|
|
future_body=future_body,
|
|
other_snakes=other_snakes,
|
|
food_set=food_set,
|
|
is_constrictor=True,
|
|
enemy_can_grow_cache=enemy_can_grow_cache,
|
|
)
|
|
blocked.discard(point)
|
|
|
|
reachable_space = self._flood_fill_count(point, blocked, width, height)
|
|
required_space = len(future_body) + 1
|
|
liberties = self._open_neighbor_count(point, blocked, width, height)
|
|
next_options = self._next_turn_option_count(
|
|
future_body, blocked, width, height
|
|
)
|
|
territory = self._territory_control_score(
|
|
my_start=point,
|
|
enemy_starts=enemy_heads,
|
|
blocked=blocked,
|
|
width=width,
|
|
height=height,
|
|
)
|
|
|
|
enemy_len = enemy_attack_map.get(point)
|
|
is_losing_head_to_head = enemy_len is not None and enemy_len >= my_len
|
|
|
|
is_dead_end = (
|
|
reachable_space < required_space or liberties == 0 or next_options == 0
|
|
)
|
|
|
|
score = 0.0
|
|
score += reachable_space * 3.8
|
|
score += liberties * 24.0
|
|
score += next_options * 16.0
|
|
score += territory * 0.65
|
|
|
|
if is_dead_end:
|
|
score -= 2600.0
|
|
|
|
if is_losing_head_to_head:
|
|
score -= 2400.0
|
|
elif enemy_len is not None:
|
|
score += 90.0
|
|
|
|
score -= self._revisit_penalty(point)
|
|
|
|
if self.last_move == move:
|
|
score += 10.0
|
|
elif (
|
|
self.last_move
|
|
and self.OPPOSITE[self.last_move] == move
|
|
and len(safe_moves) > 1
|
|
):
|
|
score -= 35.0
|
|
|
|
move_safety[move] = {
|
|
"is_survivable": (not is_dead_end) and (not is_losing_head_to_head),
|
|
"reachable_space": reachable_space,
|
|
}
|
|
scores[move] = round(score, 5)
|
|
|
|
survivable_moves = [
|
|
move for move, data in move_safety.items() if data["is_survivable"]
|
|
]
|
|
if survivable_moves:
|
|
best_space = max(
|
|
move_safety[move]["reachable_space"] for move in survivable_moves
|
|
)
|
|
considered_moves = [
|
|
move
|
|
for move in survivable_moves
|
|
if move_safety[move]["reachable_space"]
|
|
>= max(1, int(best_space * 0.70))
|
|
]
|
|
if not considered_moves:
|
|
considered_moves = survivable_moves
|
|
else:
|
|
considered_moves = list(scores.keys())
|
|
|
|
best_score = max(scores[move] for move in considered_moves)
|
|
top_moves = [
|
|
move for move in considered_moves if best_score - scores[move] <= 2.0
|
|
]
|
|
return random.choice(top_moves), scores
|
|
|
|
def _legal_moves(self, my_head:Coord, my_body:list[Coord], other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, width:int, height:int) -> MoveMap:
|
|
"""Return legal immediate moves after body, wall, and tail checks."""
|
|
occupied = self._occupied_cells(my_body, other_snakes)
|
|
own_tail = (my_body[-1]["x"], my_body[-1]["y"])
|
|
own_tail_stacked = self._is_tail_stacked(my_body)
|
|
|
|
safe_moves = {}
|
|
for move, (dx, dy) in self.DIRECTIONS.items():
|
|
point = (my_head["x"] + dx, my_head["y"] + dy)
|
|
if not self._in_bounds(point, width, height):
|
|
continue
|
|
|
|
ate_food = point in food_set
|
|
can_step_on_tail = self._can_step_on_own_tail(
|
|
point=point,
|
|
own_tail=own_tail,
|
|
own_tail_is_stacked=own_tail_stacked,
|
|
ate_food=ate_food,
|
|
is_constrictor=is_constrictor,
|
|
)
|
|
|
|
if point in occupied and not can_step_on_tail:
|
|
continue
|
|
|
|
safe_moves[move] = {"x": point[0], "y": point[1]}
|
|
|
|
return safe_moves
|
|
|
|
def _occupied_cells(self, my_body:list[Coord], other_snakes:list[SnakeState]) -> set[Point]:
|
|
"""Build a set of occupied coordinates for all snake bodies."""
|
|
occupied = {(segment["x"], segment["y"]) for segment in my_body}
|
|
for snake in other_snakes:
|
|
occupied |= {(segment["x"], segment["y"]) for segment in snake["body"]}
|
|
return occupied
|
|
|
|
def _simulation_blocked(self, future_body:list[Coord], other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, enemy_can_grow_cache:dict[Any, bool]|None=None) -> set[Point]:
|
|
"""Build blocked cells for evaluating the board one turn ahead."""
|
|
blocked = {(segment["x"], segment["y"]) for segment in future_body}
|
|
|
|
if not is_constrictor and not self._is_tail_stacked(future_body):
|
|
my_tail = future_body[-1]
|
|
blocked.discard((my_tail["x"], my_tail["y"]))
|
|
|
|
for snake in other_snakes:
|
|
for segment in snake["body"]:
|
|
blocked.add((segment["x"], segment["y"]))
|
|
|
|
if is_constrictor:
|
|
continue
|
|
|
|
snake_id = snake.get("id")
|
|
enemy_can_grow = (
|
|
enemy_can_grow_cache.get(snake_id)
|
|
if enemy_can_grow_cache and snake_id is not None
|
|
else self._enemy_can_grow_this_turn(snake, food_set)
|
|
)
|
|
if enemy_can_grow:
|
|
continue
|
|
|
|
if self._is_tail_stacked(snake["body"]):
|
|
continue
|
|
|
|
enemy_tail = snake["body"][-1]
|
|
blocked.discard((enemy_tail["x"], enemy_tail["y"]))
|
|
|
|
return blocked
|
|
|
|
def _build_enemy_attack_map(self, my_snake:SnakeState, other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, width:int, height:int, enemy_can_grow_cache:dict[Any, bool]|None=None) -> AttackMap:
|
|
"""Map cells enemies can contest next turn to their effective length."""
|
|
occupied = self._occupied_cells(my_snake["body"], other_snakes)
|
|
my_body_points = {(segment["x"], segment["y"]) for segment in my_snake["body"]}
|
|
attack_map = {}
|
|
|
|
for enemy in other_snakes:
|
|
enemy_len = enemy.get("length", len(enemy["body"]))
|
|
enemy_tail = (enemy["body"][-1]["x"], enemy["body"][-1]["y"])
|
|
enemy_tail_stacked = self._is_tail_stacked(enemy["body"])
|
|
snake_id = enemy.get("id")
|
|
enemy_can_grow = (
|
|
enemy_can_grow_cache.get(snake_id)
|
|
if enemy_can_grow_cache and snake_id is not None
|
|
else self._enemy_can_grow_this_turn(enemy, food_set)
|
|
)
|
|
|
|
enemy_head = enemy["head"]
|
|
for dx, dy in self.DIRECTIONS.values():
|
|
point = (enemy_head["x"] + dx, enemy_head["y"] + dy)
|
|
if not self._in_bounds(point, width, height):
|
|
continue
|
|
|
|
can_step_on_enemy_tail = (
|
|
not is_constrictor
|
|
and point == enemy_tail
|
|
and not enemy_tail_stacked
|
|
and not enemy_can_grow
|
|
)
|
|
|
|
if point in occupied and not can_step_on_enemy_tail:
|
|
continue
|
|
|
|
# Do not consider impossible overlap directly into my own occupied body except head swap possibilities.
|
|
if point in my_body_points:
|
|
continue
|
|
|
|
previous = attack_map.get(point)
|
|
if previous is None or enemy_len > previous:
|
|
attack_map[point] = enemy_len
|
|
|
|
return attack_map
|
|
|
|
def _future_body(self, current_body:list[Coord], next_head:Coord, ate_food:bool, is_constrictor:bool) -> list[Coord]:
|
|
"""Simulate future body segments after a candidate move."""
|
|
next_body = [next_head]
|
|
next_body.extend(current_body)
|
|
|
|
if is_constrictor or ate_food:
|
|
return next_body
|
|
|
|
next_body.pop()
|
|
return next_body
|
|
|
|
def _can_step_on_own_tail(self, point:Point, own_tail:Point, own_tail_is_stacked:bool, ate_food:bool, is_constrictor:bool) -> bool:
|
|
"""Return whether stepping onto our tail is allowed this turn."""
|
|
if is_constrictor:
|
|
return False
|
|
if ate_food:
|
|
return False
|
|
if own_tail_is_stacked:
|
|
return False
|
|
return point == own_tail
|
|
|
|
def _is_tail_stacked(self, body:list[Coord]) -> bool:
|
|
"""Check whether tail overlaps the previous body segment."""
|
|
if len(body) < 2:
|
|
return False
|
|
return body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"]
|
|
|
|
def _enemy_can_grow_this_turn(self, snake:SnakeState, food_set:set[Point]) -> bool:
|
|
"""Return True if an enemy can eat food in one move."""
|
|
head = snake["head"]
|
|
for dx, dy in self.DIRECTIONS.values():
|
|
if (head["x"] + dx, head["y"] + dy) in food_set:
|
|
return True
|
|
return False
|
|
|
|
def _hazard_damage_per_turn(self, game_data:dict) -> int:
|
|
"""Read royale hazard damage from ruleset settings.
|
|
|
|
Docs: https://docs.battlesnake.com/maps/royale
|
|
"""
|
|
ruleset = {}
|
|
if hasattr(game_data, "get_ruleset"):
|
|
ruleset = game_data.get_ruleset() or {}
|
|
elif hasattr(game_data, "ruleset"):
|
|
ruleset = game_data.ruleset or {}
|
|
settings = ruleset.get("settings", {}) if isinstance(ruleset, dict) else {}
|
|
return int(settings.get("hazardDamagePerTurn", 15))
|
|
|
|
def _hazard_is_active(self, point:Point, ate_food:bool, hazard_set:set[Point], previous_hazard_set:set[Point]) -> bool:
|
|
"""Apply royale hazard grace and food-exception behavior.
|
|
|
|
Docs: https://docs.battlesnake.com/maps/royale
|
|
"""
|
|
if point not in hazard_set:
|
|
return False
|
|
if ate_food:
|
|
return False
|
|
return point in previous_hazard_set
|
|
|
|
def _nearest_food_distance(self, start:Point, food_set:set[Point], blocked:set[Point], width:int, height:int) -> int|None:
|
|
"""Compute shortest reachable distance to any food using BFS."""
|
|
if not food_set:
|
|
return None
|
|
queue = deque([(start, 0)])
|
|
seen = {start}
|
|
|
|
while queue:
|
|
point, distance = queue.popleft()
|
|
if point in food_set:
|
|
return distance
|
|
|
|
for neighbor in self._neighbors(point):
|
|
if neighbor in seen:
|
|
continue
|
|
if not self._in_bounds(neighbor, width, height):
|
|
continue
|
|
if neighbor in blocked and neighbor not in food_set:
|
|
continue
|
|
seen.add(neighbor)
|
|
queue.append((neighbor, distance + 1))
|
|
|
|
return None
|
|
|
|
def _path_distance( self, start:Point, goal:Point, blocked:set[Point], width:int, height:int) -> int|None:
|
|
"""Compute shortest path distance between two cells."""
|
|
queue = deque([(start, 0)])
|
|
seen = {start}
|
|
|
|
while queue:
|
|
point, distance = queue.popleft()
|
|
if point == goal:
|
|
return distance
|
|
|
|
for neighbor in self._neighbors(point):
|
|
if neighbor in seen:
|
|
continue
|
|
if not self._in_bounds(neighbor, width, height):
|
|
continue
|
|
if neighbor in blocked and neighbor != goal:
|
|
continue
|
|
seen.add(neighbor)
|
|
queue.append((neighbor, distance + 1))
|
|
|
|
return None
|
|
|
|
def _flood_fill_count(self, start:Point, blocked:set[Point], width:int, height:int) -> int:
|
|
"""Count reachable cells from `start` using flood fill."""
|
|
queue = deque([start])
|
|
seen = {start}
|
|
|
|
while queue:
|
|
point = queue.popleft()
|
|
for neighbor in self._neighbors(point):
|
|
if neighbor in seen:
|
|
continue
|
|
if not self._in_bounds(neighbor, width, height):
|
|
continue
|
|
if neighbor in blocked:
|
|
continue
|
|
seen.add(neighbor)
|
|
queue.append(neighbor)
|
|
|
|
return len(seen)
|
|
|
|
def _open_neighbor_count(self, start:Point, blocked:set[Point], width:int, height:int) -> int:
|
|
"""Count walkable orthogonal neighbors around `start`."""
|
|
count = 0
|
|
for neighbor in self._neighbors(start):
|
|
if not self._in_bounds(neighbor, width, height):
|
|
continue
|
|
if neighbor in blocked:
|
|
continue
|
|
count += 1
|
|
return count
|
|
|
|
def _next_turn_option_count(self, future_body:list[Coord], blocked:set[Point], width:int, height:int) -> int:
|
|
"""Estimate options available after the next simulated turn."""
|
|
if not future_body:
|
|
return 0
|
|
|
|
next_head = future_body[0]
|
|
count = 0
|
|
for dx, dy in self.DIRECTIONS.values():
|
|
point = (next_head["x"] + dx, next_head["y"] + dy)
|
|
if not self._in_bounds(point, width, height):
|
|
continue
|
|
if point in blocked:
|
|
continue
|
|
count += 1
|
|
return count
|
|
|
|
def _revisit_penalty(self, point:Point) -> float:
|
|
"""Return penalty for revisiting recent head positions."""
|
|
if not self.recent_heads:
|
|
return 0.0
|
|
penalty = 0.0
|
|
for index, old_point in enumerate(reversed(self.recent_heads), start=1):
|
|
if old_point != point:
|
|
continue
|
|
penalty += max(0.0, 18.0 - index * 2.0)
|
|
return penalty
|
|
|
|
def _territory_control_score(self, my_start:Point, enemy_starts:list[Point], blocked:set[Point], width:int, height:int) -> int:
|
|
"""Estimate territorial advantage versus enemy start positions."""
|
|
if not enemy_starts:
|
|
return 0
|
|
|
|
my_distances = self._distance_map(my_start, blocked, width, height)
|
|
enemy_maps = [
|
|
self._distance_map(start, blocked, width, height) for start in enemy_starts
|
|
]
|
|
|
|
score = 0
|
|
for x in range(width):
|
|
for y in range(height):
|
|
point = (x, y)
|
|
if point in blocked:
|
|
continue
|
|
|
|
my_distance = my_distances.get(point)
|
|
if my_distance is None:
|
|
continue
|
|
|
|
enemy_best = None
|
|
for enemy_map in enemy_maps:
|
|
enemy_distance = enemy_map.get(point)
|
|
if enemy_distance is None:
|
|
continue
|
|
if enemy_best is None or enemy_distance < enemy_best:
|
|
enemy_best = enemy_distance
|
|
|
|
if enemy_best is None or my_distance < enemy_best:
|
|
score += 1
|
|
elif enemy_best < my_distance:
|
|
score -= 1
|
|
|
|
return score
|
|
|
|
def _distance_map(self, start:Point, blocked:set[Point], width:int, height:int) -> dict[Point, int]:
|
|
"""Build a BFS distance map from the given start cell."""
|
|
queue = deque([(start, 0)])
|
|
distances = {start: 0}
|
|
|
|
while queue:
|
|
point, distance = queue.popleft()
|
|
for neighbor in self._neighbors(point):
|
|
if neighbor in distances:
|
|
continue
|
|
if not self._in_bounds(neighbor, width, height):
|
|
continue
|
|
if neighbor in blocked:
|
|
continue
|
|
distances[neighbor] = distance + 1
|
|
queue.append((neighbor, distance + 1))
|
|
|
|
return distances
|
|
|
|
def _enemy_confinement_metrics(self, enemy_head:Point, blocked:set[Point], width:int, height:int) -> tuple[int, int]:
|
|
"""Return enemy reachable space and immediate exit count."""
|
|
enemy_blocked = set(blocked)
|
|
enemy_blocked.discard(enemy_head)
|
|
enemy_space = self._flood_fill_count(enemy_head, enemy_blocked, width, height)
|
|
enemy_options = self._open_neighbor_count(
|
|
enemy_head, enemy_blocked, width, height
|
|
)
|
|
return enemy_space, enemy_options
|
|
|
|
def _neighbors(self, point:Point) -> Iterator[Point]:
|
|
"""Yield orthogonal neighbor coordinates for a point."""
|
|
for dx, dy in self.DIRECTIONS.values():
|
|
yield (point[0] + dx, point[1] + dy)
|
|
|
|
def _manhattan(self, a:Point, b:Point) -> int:
|
|
"""Return Manhattan distance between two points."""
|
|
return abs(a[0] - b[0]) + abs(a[1] - b[1])
|
|
|
|
def _in_bounds(self, point:Point, width:int, height:int) -> bool:
|
|
"""Return True when a point is inside board boundaries."""
|
|
return 0 <= point[0] < width and 0 <= point[1] < height
|
|
|
|
def _fallback_move(self, head:Coord, width:int, height:int) -> str:
|
|
"""Pick the first in-bounds move as emergency fallback."""
|
|
for move, (dx, dy) in self.DIRECTIONS.items():
|
|
point = (head["x"] + dx, head["y"] + dy)
|
|
if self._in_bounds(point, width, height):
|
|
return move
|
|
return "up"
|