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"