diff --git a/snakes/BestBattleSnake.py b/snakes/BestBattleSnake.py index 83e391d..ea0d5a0 100644 --- a/snakes/BestBattleSnake.py +++ b/snakes/BestBattleSnake.py @@ -2,7 +2,7 @@ from collections.abc import Iterator from collections import deque from typing import Any, cast from time import perf_counter -import random, os +import os from server.dataset.RLBootstrapDataset import RLBootstrapDataset @@ -10,7 +10,7 @@ from snakes.TemplateSnake import TemplateSnake from server.GameBoard import GameBoard class BestBattleSnake(TemplateSnake): - VERSION = "2.6.1" + VERSION = "2.7.0" Point = tuple[int, int] Coord = dict[str, int] SnakeState = dict[str, Any] @@ -39,6 +39,7 @@ class BestBattleSnake(TemplateSnake): self.last_move = None self.last_game_id = None self.previous_hazards = set() + self.hazard_stack_counts:dict[tuple[int, int], int] = {} self.duel_style = self._get_duel_style() self.timeout_buffer_ms = self._get_timeout_buffer_ms() self.rl_bootstrap = RLBootstrapDataset() @@ -124,15 +125,18 @@ class BestBattleSnake(TemplateSnake): 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" + total_body_cells = len(my_body) + sum(len(snake["body"]) for snake in other_snakes) + occupancy_ratio = total_body_cells / board_area + preserve_space_mode = occupancy_ratio >= 0.34 and my_health > 35 + food_set = {(food["x"], food["y"]) for food in foods} hazard_set = {(hazard["x"], hazard["y"]) for hazard in hazards} + self.hazard_stack_counts = self._hazard_stack_count_map(hazards) previous_hazard_set = set(self.previous_hazards) hazard_damage = self._hazard_damage_per_turn(game_data) enemy_heads = [ @@ -159,13 +163,11 @@ class BestBattleSnake(TemplateSnake): 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.add_to_history({ + "turn": turn, + "move": fallback, + "reason": "no_safe_moves", + }) self.rl_bootstrap.record_sample(game_data, fallback, safe_moves, "no_safe_moves") self.previous_hazards = set(hazard_set) return fallback @@ -302,9 +304,16 @@ class BestBattleSnake(TemplateSnake): else: score += 70.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 -= self._hazard_cell_damage(point, hazard_damage) + 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) + if my_health < 40 and nearest_food_dist >= health_after_move: + score -= 800.0 + (40 - my_health) * 20.0 elif my_health < 30: score -= 150.0 @@ -323,7 +332,7 @@ class BestBattleSnake(TemplateSnake): score -= 40.0 if point in hazard_set: - hazard_scale = max(0.5, hazard_damage / 14.0) + hazard_scale = max(0.5, self._hazard_cell_damage(point, hazard_damage) / 14.0) if not ate_food: score -= (70.0 if my_health > 35 else 250.0) * hazard_scale @@ -338,10 +347,6 @@ class BestBattleSnake(TemplateSnake): ): 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 @@ -364,11 +369,15 @@ class BestBattleSnake(TemplateSnake): quick_move = ( self.last_move if self.last_move in safe_moves - else random.choice(list(safe_moves.keys())) + else self._deterministic_move_choice(list(safe_moves.keys())) ) self.recent_heads.append(current_head_point) self.last_move = quick_move - self.add_to_history({"turn": turn, "move": quick_move, "reason": "timeout_budget"}) + self.add_to_history({ + "turn": turn, + "move": quick_move, + "reason": "timeout_budget" + }) self.rl_bootstrap.record_sample(game_data, quick_move, safe_moves, "timeout_budget") self.previous_hazards = set(hazard_set) return quick_move @@ -412,7 +421,11 @@ class BestBattleSnake(TemplateSnake): ) self.recent_heads.append(current_head_point) self.last_move = best_move - self.add_to_history({"turn": turn, "move": best_move, "scores": scores}) + self.add_to_history({ + "turn": turn, + "move": best_move, + "scores": scores + }) self.rl_bootstrap.record_sample(game_data, best_move, safe_moves, "multi", scores) self.previous_hazards = set(hazard_set) return best_move @@ -528,11 +541,18 @@ class BestBattleSnake(TemplateSnake): if direct_head_distance == 1: score -= 180.0 * duel_weights["distance_safety"] + 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 -= self._hazard_cell_damage(point, hazard_damage) + 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 my_health < 40 and nearest_food_dist >= health_after_move: + score -= 800.0 + (40 - my_health) * 20.0 if ate_food: if likely_dead_end: @@ -546,7 +566,7 @@ class BestBattleSnake(TemplateSnake): score -= 50.0 if point in hazard_set: - hazard_scale = max(0.5, hazard_damage / 14.0) + hazard_scale = max(0.5, self._hazard_cell_damage(point, hazard_damage) / 14.0) if not ate_food: score -= (70.0 if my_health > 35 else 250.0) * hazard_scale @@ -561,10 +581,6 @@ class BestBattleSnake(TemplateSnake): ): 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 @@ -604,7 +620,7 @@ class BestBattleSnake(TemplateSnake): considered_moves = list(scores.keys()) if not scores: - return random.choice(list(safe_moves.keys())), {} + return self._deterministic_move_choice(list(safe_moves.keys())), {} best_move = self._pick_best_with_future_planning( considered_moves=considered_moves, @@ -744,7 +760,7 @@ class BestBattleSnake(TemplateSnake): considered_moves = list(scores.keys()) if not scores: - return random.choice(list(safe_moves.keys())), {} + return self._deterministic_move_choice(list(safe_moves.keys())), {} best_move = self._pick_best_with_future_planning( considered_moves=considered_moves, @@ -767,6 +783,15 @@ class BestBattleSnake(TemplateSnake): 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) + vacatable_enemy_tails: set[tuple[int, int]] = set() + if not is_constrictor: + for snake in other_snakes: + if self._is_tail_stacked(snake["body"]): + continue + if self._enemy_can_grow_this_turn(snake, food_set): + continue + tail = snake["body"][-1] + vacatable_enemy_tails.add((tail["x"], tail["y"])) safe_moves = {} for move, (dx, dy) in self.DIRECTIONS.items(): @@ -783,7 +808,12 @@ class BestBattleSnake(TemplateSnake): is_constrictor=is_constrictor, ) - if point in occupied and not can_step_on_tail: + can_step_on_enemy_tail = point in vacatable_enemy_tails + if ( + point in occupied + and not can_step_on_tail + and not can_step_on_enemy_tail + ): continue safe_moves[move] = {"x": point[0], "y": point[1]} @@ -859,9 +889,15 @@ class BestBattleSnake(TemplateSnake): and not enemy_tail_stacked and not enemy_can_grow ) - can_contest_my_tail = (not is_constrictor and point == my_tail and not my_tail_stacked) + can_contest_my_tail = ( + not is_constrictor and point == my_tail and not my_tail_stacked + ) - if point in occupied and not can_step_on_enemy_tail and not can_contest_my_tail: + if ( + point in occupied + and not can_step_on_enemy_tail + and not can_contest_my_tail + ): continue # Ignore impossible overlap into our occupied body, but keep our vacatable tail @@ -924,6 +960,19 @@ class BestBattleSnake(TemplateSnake): settings = ruleset.get("settings", {}) if isinstance(ruleset, dict) else {} return int(settings.get("hazardDamagePerTurn", 15)) + def _hazard_stack_count_map(self, hazards:list[Coord]) -> dict[Point, int]: + counts: dict[tuple[int, int], int] = {} + for hazard in hazards: + point = (hazard["x"], hazard["y"]) + counts[point] = counts.get(point, 0) + 1 + return counts + + def _hazard_cell_damage(self, point:Point, hazard_damage:int) -> int: + stack = self.hazard_stack_counts.get(point, 0) + if stack <= 0: + return 0 + return hazard_damage * stack + 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. @@ -952,8 +1001,11 @@ class BestBattleSnake(TemplateSnake): if len(top_moves) <= 1: return top_moves[0] - if self._time_exceeded(deadline) or self._remaining_ms(deadline) < self.future_planning_min_time_ms: - return random.choice(top_moves) + if ( + self._time_exceeded(deadline) + or self._remaining_ms(deadline) < self.future_planning_min_time_ms + ): + return self._deterministic_move_choice(top_moves, scores) candidate_limit = max(1, self.future_planning_branch) candidate_moves = sorted(top_moves, key=lambda move: scores[move], reverse=True)[:candidate_limit] @@ -978,7 +1030,7 @@ class BestBattleSnake(TemplateSnake): lookahead_bonus[move] = bonus if not lookahead_bonus: - return random.choice(top_moves) + return self._deterministic_move_choice(top_moves, scores) for move, bonus in lookahead_bonus.items(): scores[move] += bonus @@ -989,7 +1041,27 @@ class BestBattleSnake(TemplateSnake): for move in top_moves if refined_best - scores[move] <= max(0.5, tie_window / 2) ] - return random.choice(refined_top) + return self._deterministic_move_choice(refined_top, scores) + + def _move_preference_key(self, move:str) -> tuple[int, int, int]: + same_as_last = 1 if self.last_move == move else 0 + is_reverse = ( + 1 + if self.last_move is not None and self.OPPOSITE.get(self.last_move) == move + else 0 + ) + direction_priority = {"up": 3, "left": 2, "right": 1, "down": 0} + return same_as_last, -is_reverse, direction_priority.get(move, -1) + + def _deterministic_move_choice(self, moves:list[str], scores:dict[str, float]|None=None) -> str: + if not moves: + return "up" + if scores is None: + return max(moves, key=self._move_preference_key) + return max( + moves, + key=lambda move: (scores.get(move, -1e9), self._move_preference_key(move)), + ) def _future_rollout_bonus_for_move(self, move:str, safe_moves:MoveMap, my_body:list[Coord], other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, width:int, height:int, enemy_can_grow_cache:dict[Any, bool]|None, depth:int, branch_limit:int, deadline:float|None) -> float: pos = safe_moves.get(move) @@ -1017,8 +1089,7 @@ class BestBattleSnake(TemplateSnake): ) return raw_score * 0.06 - def _future_survival_tree_score(self, my_body:list[Coord], other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, width:int, height:int, enemy_can_grow_cache:dict[Any, bool]|None, depth:int, branch_limit:int, deadline:float|None, - ) -> float: + def _future_survival_tree_score(self, my_body:list[Coord], other_snakes:list[SnakeState], food_set:set[Point], is_constrictor:bool, width:int, height:int, enemy_can_grow_cache:dict[Any, bool]|None, depth:int, branch_limit:int, deadline:float|None) -> float: if depth <= 0 or self._time_exceeded(deadline): return 0.0 diff --git a/snakes/UltimateBattleSnake.py b/snakes/UltimateBattleSnake.py index d2fdc70..7cc05ba 100644 --- a/snakes/UltimateBattleSnake.py +++ b/snakes/UltimateBattleSnake.py @@ -2,7 +2,7 @@ from collections.abc import Iterator from collections import deque from typing import Any, cast from time import perf_counter -import random, os +import heapq, os from snakes.TemplateSnake import TemplateSnake from server.GameBoard import GameBoard @@ -10,7 +10,7 @@ from server.dataset.RLBootstrapDataset import RLBootstrapDataset class UltimateBattleSnake(TemplateSnake): """ - UltimateBattleSnake v4.2.0 + UltimateBattleSnake v4.4.0 All improvements over BestBattleSnake: v3: #1+#9 Simultaneous minimax (both snakes move at once) with hazard/health tracking @@ -42,9 +42,17 @@ class UltimateBattleSnake(TemplateSnake): v4.2 B4 _build_enemy_attack_map: enemy_can_grow now actually passed from choose_move and tree v4.2 B5 _choose_duel_move: removed double food bias (score_move already adds it; duel now only adjusts delta) v4.2 B6 _minimax_sim: occupancy now respects _is_tail_stacked (stacked tail not vacated) + v4.3 C1 hazard_count dict tracks Snail Mode stack depth; damage scaled by stack throughout + v4.3 C2 minimax: hazard damage skipped when food eaten on same tile (rules fidelity) + v4.3 C3 _hazard_will_kill: baseline -1/turn now included in health depletion math + v4.3 C4 _legal_moves: enemy tail vacate allowed when enemy won't grow (fixes false negatives) + v4.3 C5 mode detection uses both ruleset name and game map (snail mode hardening) + v4.4 D1 _minimax_sim: hazard spawn-immunity via previous_hazard_set (no damage on newly-spawned hazard) + v4.4 D2 _hazard_will_kill: Dijkstra with per-tile stack cost (was constant entry_stack for whole corridor) + v4.4 D3 all random.choice fallbacks replaced with deterministic degrade (last_move > center > lexical) """ - VERSION = "4.2.0" + VERSION = "4.4.0" Point = tuple[int, int] Coord = dict[str, int] SnakeState = dict[str, Any] @@ -148,10 +156,20 @@ class UltimateBattleSnake(TemplateSnake): foods = game_data.get_food() hazards = game_data.get_hazard() other_snakes = game_data.get_other_snakes() - is_constrictor = game_data.get_type() == "constrictor" + # C5: use both ruleset name AND game map for robust mode detection + game_type = game_data.get_type() + game_map = game_data.get_map() if hasattr(game_data, "get_map") else None + is_constrictor = game_type == "constrictor" + is_snail = game_map in {"snail_mode", "snail"} or game_type == "snail_mode" food_set: set[tuple[int, int]] = {(f["x"], f["y"]) for f in foods} - hazard_set: set[tuple[int, int]] = {(h["x"], h["y"]) for h in hazards} + # C1: track hazard stack depth (Snail Mode can stack multiple hazards on one tile) + hazard_set: set[tuple[int, int]] = set() + hazard_count: dict[tuple[int, int], int] = {} + for h in hazards: + pt = (h["x"], h["y"]) + hazard_set.add(pt) + hazard_count[pt] = hazard_count.get(pt, 0) + 1 previous_hazard_set = set(self.previous_hazards) hazard_damage = self._hazard_damage_per_turn(game_data) current_head_pt = (my_head["x"], my_head["y"]) @@ -187,6 +205,7 @@ class UltimateBattleSnake(TemplateSnake): 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, + enemy_can_grow=enemy_can_grow, ) if not safe_moves: @@ -207,7 +226,8 @@ class UltimateBattleSnake(TemplateSnake): best_move, scores = self._choose_constrictor_move( safe_moves=safe_moves, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, ) @@ -216,7 +236,8 @@ class UltimateBattleSnake(TemplateSnake): best_move, scores = self._choose_duel_move( safe_moves=safe_moves, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, ) @@ -225,7 +246,8 @@ class UltimateBattleSnake(TemplateSnake): best_move, scores = self._choose_multi_move( safe_moves=safe_moves, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, ) @@ -251,8 +273,9 @@ class UltimateBattleSnake(TemplateSnake): def _choose_multi_move( self, safe_moves: MoveMap, my_body: list, my_len: int, my_health: int, other_snakes: list, food_set: set, hazard_set: set, hazard_damage: int, - previous_hazard_set: set, enemy_attack_map: dict, enemy_can_grow: dict, - total_occupancy: float, width: int, height: int, deadline: float | None, + hazard_count: dict, previous_hazard_set: set, enemy_attack_map: dict, + enemy_can_grow: dict, total_occupancy: float, width: int, height: int, + deadline: float | None, ) -> tuple[str, dict[str, float]]: scores: dict[str, float] = {} safety: dict[str, dict] = {} @@ -263,7 +286,8 @@ class UltimateBattleSnake(TemplateSnake): sc, info = self._score_move( move=move, pos=pos, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, is_constrictor=False, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, @@ -275,7 +299,7 @@ class UltimateBattleSnake(TemplateSnake): safety[move] = info if not scores: - quick = self.last_move if self.last_move in safe_moves else random.choice(list(safe_moves)) + quick = self._deterministic_fallback(safe_moves, width, height) self.add_to_history({ "turn": self.game_board.get_turn(), "mode": "multi", "move": quick, "reason": "timeout_budget", @@ -324,8 +348,9 @@ class UltimateBattleSnake(TemplateSnake): def _choose_duel_move( self, safe_moves: MoveMap, my_body: list, my_len: int, my_health: int, other_snakes: list, food_set: set, hazard_set: set, hazard_damage: int, - previous_hazard_set: set, enemy_attack_map: dict, enemy_can_grow: dict, - total_occupancy: float, width: int, height: int, deadline: float | None, + hazard_count: dict, previous_hazard_set: set, enemy_attack_map: dict, + enemy_can_grow: dict, total_occupancy: float, width: int, height: int, + deadline: float | None, ) -> tuple[str, dict[str, float]]: enemy = other_snakes[0] enemy_len = enemy.get("length", len(enemy["body"])) @@ -344,7 +369,8 @@ class UltimateBattleSnake(TemplateSnake): sc, info = self._score_move( move=move, pos=pos, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, is_constrictor=False, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, @@ -399,7 +425,7 @@ class UltimateBattleSnake(TemplateSnake): safety[move] = info if not scores: - return random.choice(list(safe_moves)), {} + return self._deterministic_fallback(safe_moves, width, height), {} best_move = self._select_best(scores, safety, safe_moves, list(scores)) @@ -416,15 +442,17 @@ class UltimateBattleSnake(TemplateSnake): ate = (pos["x"], pos["y"]) in food_set fb = self._future_body(my_body, pos, ate, False) nmy_h = 100 if ate else my_health - 1 - if (pos["x"], pos["y"]) in hazard_set: - nmy_h -= hazard_damage + # C2: food eaten on hazard tile — no hazard penalty this turn (rules fidelity) + if (pos["x"], pos["y"]) in hazard_set and not ate: + nmy_h -= hazard_damage * hazard_count.get((pos["x"], pos["y"]), 1) mm_val = self._minimax_sim( my_body=fb, enemy_body=enemy["body"], food_set=food_set, hazard_set=hazard_set, my_health=nmy_h, enemy_health=enemy_health, - hazard_damage=hazard_damage, + hazard_damage=hazard_damage, hazard_count=hazard_count, width=width, height=height, depth=2, alpha=-1e9, beta=1e9, deadline=deadline, + previous_hazard_set=previous_hazard_set, # D1: spawn-immunity for first minimax level ) mm_scores[m] = scores[m] + mm_val * 0.10 if mm_scores: @@ -476,8 +504,9 @@ class UltimateBattleSnake(TemplateSnake): def _choose_constrictor_move( self, safe_moves: MoveMap, my_body: list, my_len: int, my_health: int, other_snakes: list, food_set: set, hazard_set: set, hazard_damage: int, - previous_hazard_set: set, enemy_attack_map: dict, enemy_can_grow: dict, - total_occupancy: float, width: int, height: int, deadline: float | None, + hazard_count: dict, previous_hazard_set: set, enemy_attack_map: dict, + enemy_can_grow: dict, total_occupancy: float, width: int, height: int, + deadline: float | None, ) -> tuple[str, dict[str, float]]: scores: dict[str, float] = {} safety: dict[str, dict] = {} @@ -488,7 +517,8 @@ class UltimateBattleSnake(TemplateSnake): sc, info = self._score_move( move=move, pos=pos, my_body=my_body, my_len=my_len, my_health=my_health, other_snakes=other_snakes, food_set=food_set, hazard_set=hazard_set, - hazard_damage=hazard_damage, previous_hazard_set=previous_hazard_set, + hazard_damage=hazard_damage, hazard_count=hazard_count, + previous_hazard_set=previous_hazard_set, is_constrictor=True, enemy_attack_map=enemy_attack_map, enemy_can_grow=enemy_can_grow, total_occupancy=total_occupancy, width=width, height=height, deadline=deadline, @@ -513,7 +543,7 @@ class UltimateBattleSnake(TemplateSnake): safety[move] = info if not scores: - return random.choice(list(safe_moves)), {} + return self._deterministic_fallback(safe_moves, width, height), {} # F2: survival tree for constrictor mode survivable_const = [m for m in scores if safety.get(m, {}).get("is_survivable", False)] @@ -554,8 +584,8 @@ class UltimateBattleSnake(TemplateSnake): def _score_move( self, move: str, pos: Coord, my_body: list, my_len: int, my_health: int, other_snakes: list, food_set: set, hazard_set: set, hazard_damage: int, - previous_hazard_set: set, is_constrictor: bool, enemy_attack_map: dict, - enemy_can_grow: dict, total_occupancy: float, + hazard_count: dict, previous_hazard_set: set, is_constrictor: bool, + enemy_attack_map: dict, enemy_can_grow: dict, total_occupancy: float, width: int, height: int, deadline: float | None, ) -> tuple[float, dict]: point = (pos["x"], pos["y"]) @@ -650,15 +680,18 @@ class UltimateBattleSnake(TemplateSnake): hunger = max(0.0, (60.0 - my_health) / 60.0) + # C1: use per-tile stack depth for accurate health simulation + hazard_stack = hazard_count.get(point, 1) if point in hazard_set else 1 # Simulated health after move health_after = 100 if ate_food else my_health - 1 + # C2: food eaten on hazard tile — no hazard penalty (rules fidelity) if point in hazard_set and not ate_food and point in previous_hazard_set: - health_after -= hazard_damage + health_after -= hazard_damage * hazard_stack # Fix #11: hazard corridor death check hazard_will_kill = ( not ate_food and point in hazard_set and point in previous_hazard_set - and self._hazard_will_kill(point, hazard_set, blocked, width, height, my_health, hazard_damage) + and self._hazard_will_kill(point, hazard_set, hazard_count, blocked, width, height, my_health, hazard_damage) ) # ── Score assembly ──────────────────────────────────────────────────────── @@ -719,7 +752,8 @@ class UltimateBattleSnake(TemplateSnake): score -= 45.0 if point in hazard_set: - scale = max(0.5, hazard_damage / 14.0) + # C1: scale penalty by stack depth (Snail Mode stacked tiles are more dangerous) + scale = max(0.5, hazard_damage * hazard_stack / 14.0) if not ate_food: score -= (80.0 if my_health > 35 else 270.0) * scale if hazard_will_kill: @@ -791,6 +825,16 @@ class UltimateBattleSnake(TemplateSnake): # ── Move selector ───────────────────────────────────────────────────────────── + def _deterministic_fallback(self, safe_moves: MoveMap, width: int = 11, height: int = 11) -> str: + """D3: Deterministic degrade ladder — last_move > center proximity > lexical order.""" + if self.last_move and self.last_move in safe_moves: + return self.last_move + cx, cy = (width - 1) / 2.0, (height - 1) / 2.0 + return min( + safe_moves, + key=lambda m: (abs(safe_moves[m]["x"] - cx) + abs(safe_moves[m]["y"] - cy), m), + ) + def _select_best( self, scores: dict[str, float], safety: dict[str, dict], safe_moves: MoveMap, considered: list[str], @@ -799,7 +843,7 @@ class UltimateBattleSnake(TemplateSnake): survivable = [m for m in considered if safety.get(m, {}).get("is_survivable", False)] pool = survivable if survivable else (considered if considered else list(scores)) if not pool: - return random.choice(list(safe_moves)) + return self._deterministic_fallback(safe_moves) best_sc = max(scores.get(m, -1e9) for m in pool) @@ -816,10 +860,13 @@ class UltimateBattleSnake(TemplateSnake): def _minimax_sim( self, my_body: list, enemy_body: list, food_set: set, hazard_set: set, - my_health: int, enemy_health: int, hazard_damage: int, + my_health: int, enemy_health: int, hazard_damage: int, hazard_count: dict, width: int, height: int, depth: int, alpha: float, beta: float, deadline: float | None, + previous_hazard_set: set | None = None, ) -> float: + # D1: spawn-immunity — effective prev set; None means treat all current hazards as old + eff_prev = previous_hazard_set if previous_hazard_set is not None else hazard_set if depth <= 0 or self._time_exceeded(deadline): return self._minimax_eval(my_body, enemy_body, width, height) @@ -877,10 +924,13 @@ class UltimateBattleSnake(TemplateSnake): nmy_h = 100 if my_ate else my_health - 1 nen_h = 100 if en_ate else enemy_health - 1 - if my_pt in hazard_set: - nmy_h -= hazard_damage - if en_pt in hazard_set: - nen_h -= hazard_damage + # C2: food consumed on hazard tile = no hazard penalty (rules fidelity) + # C1: scale damage by stack depth for Snail Mode accuracy + # D1: spawn-immunity — only charge damage if hazard existed before this move + if my_pt in hazard_set and not my_ate and my_pt in eff_prev: + nmy_h -= hazard_damage * hazard_count.get(my_pt, 1) + if en_pt in hazard_set and not en_ate and en_pt in eff_prev: + nen_h -= hazard_damage * hazard_count.get(en_pt, 1) if nmy_h <= 0 and nen_h <= 0: val = -500.0 @@ -891,8 +941,9 @@ class UltimateBattleSnake(TemplateSnake): else: val = self._minimax_sim( new_my, new_en, food_set, hazard_set, - nmy_h, nen_h, hazard_damage, width, height, + nmy_h, nen_h, hazard_damage, hazard_count, width, height, depth - 1, alpha, beta, deadline, + previous_hazard_set=hazard_set, # D1: after this turn, current hazards are old ) worst = min(worst, val) @@ -956,7 +1007,7 @@ class UltimateBattleSnake(TemplateSnake): if depth <= 0 or self._time_exceeded(deadline): return 0.0 my_head = my_body[0] - moves = self._legal_moves(my_head, my_body, other_snakes, food_set, is_constrictor, width, height) + moves = self._legal_moves(my_head, my_body, other_snakes, food_set, is_constrictor, width, height, enemy_can_grow) if not moves: return -5000.0 # no legal moves = dead @@ -1093,24 +1144,35 @@ class UltimateBattleSnake(TemplateSnake): # ── Hazard multi-step check (fix #11) ──────────────────────────────────────── def _hazard_will_kill( - self, point: tuple, hazard_set: set, blocked: set, + self, point: tuple, hazard_set: set, hazard_count: dict, blocked: set, width: int, height: int, health: int, hazard_damage: int, ) -> bool: - """Return True if entering this hazard cell leads to death before reaching a safe cell.""" + """Return True if entering this hazard cell leads to death before reaching a safe cell. + C3: each turn in hazard costs 1 (baseline) + hazard_damage * stack; exit costs 1. + D2: Dijkstra with per-tile stack cost replaces fixed entry_stack for entire corridor. + """ if hazard_damage <= 0: return False - queue: deque[tuple[tuple, int]] = deque([(point, 0)]) - seen = {point} - while queue: - pt, steps = queue.popleft() + # D2: Dijkstra — accumulate damage per tile to find minimum-cost path to any non-hazard cell + # Entry cost includes this turn's hazard damage for landing on point + entry_cost = 1 + hazard_damage * hazard_count.get(point, 1) + heap: list[tuple[int, tuple]] = [(entry_cost, point)] + best: dict[tuple, int] = {point: entry_cost} + while heap: + cost, pt = heapq.heappop(heap) + if cost > best.get(pt, 10**9): + continue if pt not in hazard_set: - # Found an exit: will we survive the journey? - return health - steps * hazard_damage <= 0 + return health - cost <= 0 for n in self._neighbors(pt): - if n not in seen and self._in_bounds(n, width, height) and n not in blocked: - seen.add(n) - queue.append((n, steps + 1)) - return True # no exit found = fatal + if not self._in_bounds(n, width, height) or n in blocked: + continue + step = (1 + hazard_damage * hazard_count.get(n, 1)) if n in hazard_set else 1 + nc = cost + step + if nc < best.get(n, 10**9): + best[n] = nc + heapq.heappush(heap, (nc, n)) + return True # no exit reachable = fatal # ── Duel + constrictor helpers ──────────────────────────────────────────────── @@ -1203,6 +1265,7 @@ class UltimateBattleSnake(TemplateSnake): def _legal_moves( self, my_head: Coord, my_body: list, other_snakes: list, food_set: set, is_constrictor: bool, width: int, height: int, + enemy_can_grow: dict | None = None, ) -> MoveMap: occupied = {(s["x"], s["y"]) for s in my_body} for snake in other_snakes: @@ -1210,6 +1273,20 @@ class UltimateBattleSnake(TemplateSnake): occupied.add((seg["x"], seg["y"])) own_tail = (my_body[-1]["x"], my_body[-1]["y"]) own_tail_stacked = self._is_tail_stacked(my_body) + # C4: collect enemy tails that will vacate this turn (enemy won't grow, not stacked) + enemy_vacating_tails: set[tuple[int, int]] = set() + if not is_constrictor: + for snake in other_snakes: + if self._is_tail_stacked(snake["body"]): + continue + snake_id = snake.get("id") + can_grow: bool | None = None + if enemy_can_grow is not None and snake_id is not None: + can_grow = enemy_can_grow.get(snake_id) + if can_grow is None: + can_grow = self._enemy_can_grow_this_turn(snake, food_set) + if not can_grow: + enemy_vacating_tails.add((snake["body"][-1]["x"], snake["body"][-1]["y"])) safe: MoveMap = {} for move, (dx, dy) in self.DIRECTIONS.items(): pt = (my_head["x"] + dx, my_head["y"] + dy) @@ -1217,6 +1294,8 @@ class UltimateBattleSnake(TemplateSnake): continue ate = pt in food_set can_step = self._can_step_on_own_tail(pt, own_tail, own_tail_stacked, ate, is_constrictor) + if not can_step and pt in enemy_vacating_tails: + can_step = True if pt in occupied and not can_step: continue safe[move] = {"x": pt[0], "y": pt[1]} diff --git a/snakes/__init__.py b/snakes/__init__.py index 20c4301..8baadab 100644 --- a/snakes/__init__.py +++ b/snakes/__init__.py @@ -8,7 +8,7 @@ SNAKE_REGISTRY = { "BetterMasterSnake": "1.3.0", "BestBattleSnake": "2.6.0", "TrainedBattleSnake": "0.1.0", - "UltimateBattleSnake": "4.2.0", + "UltimateBattleSnake": "4.4.0", } def build_snake(selected_snake: str): diff --git a/tests/snakes/test_UltimateBattleSnake.py b/tests/snakes/test_UltimateBattleSnake.py new file mode 100644 index 0000000..cd19863 --- /dev/null +++ b/tests/snakes/test_UltimateBattleSnake.py @@ -0,0 +1,1026 @@ +import unittest + +from snakes.UltimateBattleSnake import UltimateBattleSnake +from server.GameBoard import GameBoard + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def make_board(game_state: dict) -> GameBoard: + snake = UltimateBattleSnake() + board = GameBoard( + game_id=game_state["game"]["id"], + width=game_state["board"]["width"], + height=game_state["board"]["height"], + ruleset=game_state["game"]["ruleset"], + source=game_state["game"].get("source", "custom"), + map=game_state["game"].get("map", "standard"), + snake_class=snake, + ) + board.read_game_data(game_state) + return board + +def move(game_state: dict) -> str: + return make_board(game_state).snake_neat_make_a_move() + +def gs( + my_body: list[tuple], + other_bodies: list[list[tuple]] | None = None, + foods: list[tuple] | None = None, + hazards: list[tuple] | None = None, + my_health: int = 90, + my_id: str = "me", + enemy_health: int = 90, + game_type: str = "standard", + game_map: str = "standard", + hazard_damage: int = 14, + width: int = 11, + height: int = 11, + turn: int = 20, + game_id: str = "test-game", +) -> dict: + """Build a minimal valid Battlesnake API game-state dict.""" + other_bodies = other_bodies or [] + foods = foods or [] + hazards = hazards or [] + + def body_dicts(coords): + return [{"x": x, "y": y} for x, y in coords] + + my_snake = { + "id": my_id, "name": "UltimateBattleSnake", "health": my_health, + "body": body_dicts(my_body), + "head": {"x": my_body[0][0], "y": my_body[0][1]}, + "length": len(my_body), + "latency": "50", "shout": "", + } + + snakes = [my_snake] + for i, body in enumerate(other_bodies): + snakes.append({ + "id": f"enemy-{i}", "name": f"Enemy{i}", "health": enemy_health, + "body": body_dicts(body), + "head": {"x": body[0][0], "y": body[0][1]}, + "length": len(body), + "latency": "60", "shout": "", + }) + + ruleset = { + "name": game_type, "version": "v1.0.0", + "settings": {"hazardDamagePerTurn": hazard_damage}, + } + + return { + "game": {"id": game_id, "ruleset": ruleset, "source": "custom", "map": game_map}, + "turn": turn, + "board": { + "height": height, "width": width, + "food": body_dicts(foods), + "hazards": body_dicts(hazards), + "snakes": snakes, + }, + "you": my_snake, + } + + +# ── Tests: basic safety ─────────────────────────────────────────────────────── + +class TestWallAndBodyAvoidance(unittest.TestCase): + + def test_avoids_left_wall(self): + """Head at x=0, must not go left.""" + result = move(gs( + my_body=[(0, 5), (1, 5), (2, 5)], + other_bodies=[[(9, 9), (9, 8), (9, 7)]], + )) + self.assertNotEqual(result, "left") + + def test_avoids_bottom_wall(self): + """Head at y=0, must not go down.""" + result = move(gs( + my_body=[(5, 0), (5, 1), (5, 2)], + other_bodies=[[(9, 9), (9, 8), (9, 7)]], + )) + self.assertNotEqual(result, "down") + + def test_avoids_right_wall(self): + """Head at x=width-1, must not go right.""" + result = move(gs( + my_body=[(10, 5), (9, 5), (8, 5)], + other_bodies=[[(1, 1), (1, 2), (1, 3)]], + )) + self.assertNotEqual(result, "right") + + def test_avoids_top_wall(self): + """Head at y=height-1, must not go up.""" + result = move(gs( + my_body=[(5, 10), (5, 9), (5, 8)], + other_bodies=[[(1, 1), (1, 2), (1, 3)]], + )) + self.assertNotEqual(result, "up") + + def test_avoids_own_body(self): + """Head at (5,5) with body going right — must not go right.""" + result = move(gs( + my_body=[(5, 5), (6, 5), (7, 5), (8, 5)], + other_bodies=[[(1, 1), (1, 2), (1, 3)]], + foods=[(5, 9)], + )) + self.assertNotEqual(result, "right") + + def test_avoids_enemy_body(self): + """Enemy body blocks a direction.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(6, 5), (7, 5), (8, 5), (9, 5), (9, 6), (9, 7)]], + )) + self.assertNotEqual(result, "right") + + def test_only_one_safe_move_taken(self): + """Boxed in three sides — must take the only open move.""" + # Head at (1,1), body wraps up and left, wall below and left + result = move(gs( + my_body=[(1, 1), (1, 2), (2, 2), (2, 1)], + other_bodies=[], + foods=[(5, 5)], + width=7, height=7, + )) + # Only right is open (wall at x=0, body above and right closes loop) + self.assertEqual(result, "right") + + def test_no_safe_moves_returns_valid_direction(self): + """Even if all moves are fatal, must return a valid direction string.""" + result = move(gs( + my_body=[(0, 0), (0, 1), (1, 1), (1, 0)], + other_bodies=[], + )) + self.assertIn(result, ("up", "down", "left", "right")) + + +# ── Tests: tail stepping ────────────────────────────────────────────────────── + +class TestTailStepping(unittest.TestCase): + + def test_can_step_on_own_tail_when_not_stacked(self): + """Snake can chase its own tail when it will vacate.""" + # Head at (3,3), body forms a U, tail at (3,2) — stepping down is valid + board = make_board(gs( + my_body=[(3, 3), (2, 3), (2, 2), (3, 2)], + other_bodies=[[(9, 9), (9, 8), (9, 7)]], + foods=[(8, 8)], + )) + snake = board.snake_class + safe = snake._legal_moves( + my_head={"x": 3, "y": 3}, + my_body=[{"x": 3, "y": 3}, {"x": 2, "y": 3}, {"x": 2, "y": 2}, {"x": 3, "y": 2}], + other_snakes=[], + food_set=set(), + is_constrictor=False, + width=11, height=11, + ) + self.assertIn("down", safe) + + def test_cannot_step_on_stacked_tail(self): + """Stacked tail (just ate) must NOT be treated as vacatable.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (4, 4), (4, 5), (4, 5)], # tail stacked at (4,5) + other_bodies=[[(9, 9), (9, 8), (9, 7)]], + foods=[(8, 8)], + )) + # (4,5) is to the left — must not be considered safe + snake = UltimateBattleSnake() + body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 4, "y": 4}, {"x": 4, "y": 5}, {"x": 4, "y": 5}] + safe = snake._legal_moves( + my_head={"x": 5, "y": 5}, + my_body=body, + other_snakes=[], + food_set=set(), + is_constrictor=False, + width=11, height=11, + ) + self.assertNotIn("left", safe) + + +# ── Tests: head-to-head collisions ──────────────────────────────────────────── + +class TestHeadToHead(unittest.TestCase): + + def test_avoids_h2h_with_equal_length_enemy(self): + """Equal-length head-to-head = both die — must avoid.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(7, 5), (7, 4), (7, 3)]], # enemy same length, head at (7,5) + foods=[(0, 0)], + )) + self.assertNotEqual(result, "right") + + def test_avoids_h2h_with_larger_enemy(self): + """Larger enemy head adjacent — must avoid that square.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(7, 5), (7, 4), (7, 3), (7, 2), (7, 1), (6, 1)]], + foods=[(0, 0)], + )) + self.assertNotEqual(result, "right") + + def test_can_head_hunt_smaller_enemy(self): + """We are clearly bigger — moving toward enemy head should be preferred.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3), (5, 2), (5, 1), (4, 1), (4, 2)], + other_bodies=[[(7, 5), (7, 4), (7, 3)]], # enemy at (7,5), we have 7 vs 3 + foods=[(0, 0)], + )) + # Moving right (toward enemy) should be chosen + self.assertEqual(result, "right") + + +# ── Tests: food ─────────────────────────────────────────────────────────────── + +class TestFoodBehavior(unittest.TestCase): + + def test_chases_food_when_critically_low_health(self): + """Health=10, food directly above — must go up.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(9, 9), (9, 8), (9, 7)]], + foods=[(5, 6)], + my_health=10, + )) + self.assertEqual(result, "up") + + def test_chases_nearest_food_when_low_health(self): + """Health=15, food to the right — must go right.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(1, 1), (1, 2), (1, 3)]], + foods=[(6, 5)], + my_health=15, + )) + self.assertEqual(result, "right") + + def test_avoids_food_in_dead_end(self): + """Food is reachable but leads to a dead end — must choose survival over food.""" + # 7x7 board: head at (3,3), food at (3,4) which is a dead end due to enemy blocking + result = move(gs( + my_body=[(3, 3), (3, 2), (3, 1)], + other_bodies=[[(2, 4), (2, 5), (3, 5), (4, 5), (4, 4)]], + foods=[(3, 4)], + width=7, height=7, + )) + self.assertNotEqual(result, "up") + + def test_starvation_penalty_applied_when_food_unreachable_in_time(self): + """Health < 40 and food is far away — starvation penalty should steer away from that path.""" + snake = UltimateBattleSnake() + # Verify the _score_move starvation logic is invoked: + # health=20, health_after=19, nearest_food=25 — food unreachable + # The penalty should reduce the score of that move noticeably + from unittest.mock import patch + # Just verify choose_move returns a valid direction without crashing + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(1, 1), (1, 2), (1, 3)]], + foods=[(0, 10)], # food far away + my_health=20, + )) + self.assertIn(result, ("up", "down", "left", "right")) + + +# ── Tests: duel mode ────────────────────────────────────────────────────────── + +class TestDuelMode(unittest.TestCase): + + def test_duel_smaller_snake_does_not_approach_enemy_head(self): + """We are shorter — must not go adjacent to enemy head.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(7, 5), (7, 4), (7, 3), (7, 2), (7, 1), (6, 1)]], + foods=[(0, 0)], + )) + self.assertNotEqual(result, "right") + + def test_duel_larger_snake_approaches_enemy(self): + """We are much bigger — head hunting should prefer moving toward enemy.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3), (4, 3), (4, 4), (4, 5), (4, 6), (5, 6)], + other_bodies=[[(7, 5), (7, 4), (7, 3)]], + foods=[(0, 0)], + )) + self.assertEqual(result, "right") + + def test_duel_encasement_reduces_enemy_space(self): + """Prefer move that tightens encasement of trapped enemy.""" + result = move(gs( + my_body=[(2, 3), (2, 2), (1, 2), (1, 3), (1, 4), (2, 4), (3, 4), (3, 5)], + other_bodies=[[(4, 3), (5, 3), (5, 2), (4, 2), (4, 1)]], + foods=[(0, 0)], + width=7, height=7, + )) + self.assertEqual(result, "right") + + def test_duel_food_bias_scale_not_doubled(self): + """Food bias in duel mode must not be double-counted vs base scoring.""" + snake = UltimateBattleSnake() + # Run two moves — one with balanced style, one with aggressive style + # Both must return a valid direction (regression: double bias could overflow scoring) + import os + os.environ["BATTLE_SNAKE_DUEL_STYLE"] = "aggressive" + result_agg = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(7, 7), (7, 6), (7, 5)]], + foods=[(6, 5)], + my_health=50, + )) + os.environ["BATTLE_SNAKE_DUEL_STYLE"] = "safe" + result_safe = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(7, 7), (7, 6), (7, 5)]], + foods=[(6, 5)], + my_health=50, + )) + os.environ.pop("BATTLE_SNAKE_DUEL_STYLE", None) + self.assertIn(result_agg, ("up", "down", "left", "right")) + self.assertIn(result_safe, ("up", "down", "left", "right")) + + def test_duel_length_growth_bonus_when_eating_crosses_threshold(self): + """Eating food that makes us equal to or longer than enemy gets bonus.""" + snake = UltimateBattleSnake() + # our len=3, enemy len=3, food at (6,5) — eating makes us len=4 > 3 + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(8, 5), (8, 4), (8, 3)]], + foods=[(6, 5)], + my_health=90, + )) + # Food is to the right and gives us a length advantage — should prefer right + self.assertEqual(result, "right") + + +# ── Tests: constrictor mode ─────────────────────────────────────────────────── + +class TestConstrictorMode(unittest.TestCase): + + def test_constrictor_tails_never_vacate(self): + """In constrictor mode, enemy tails stay blocked.""" + snake = UltimateBattleSnake() + enemy_body = [ + {"x": 3, "y": 3}, {"x": 3, "y": 2}, {"x": 2, "y": 2}, {"x": 2, "y": 3} + ] + future_body = [ + {"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3} + ] + blocked = snake._simulation_blocked( + future_body=future_body, + other_snakes=[{"id": "e", "body": enemy_body, "head": {"x": 3, "y": 3}}], + food_set=set(), + is_constrictor=True, + ) + # Enemy tail at (2,3) must still be blocked in constrictor + self.assertIn((2, 3), blocked) + + def test_constrictor_avoids_dead_end_with_buffer(self): + """Constrictor dead-end buffer (required_space += max(3, len//6)) keeps snake safe.""" + result = move(gs( + my_body=[(1, 1), (1, 0), (0, 0), (0, 1)], + other_bodies=[[(4, 4), (3, 4), (3, 3), (2, 3), (2, 2), (2, 0), (3, 1)]], + game_type="constrictor", + width=7, height=7, + )) + self.assertEqual(result, "up") + + def test_constrictor_returns_valid_move(self): + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(3, 3), (3, 4), (3, 5)]], + game_type="constrictor", + )) + self.assertIn(result, ("up", "down", "left", "right")) + + +# ── Tests: multi-snake mode ─────────────────────────────────────────────────── + +class TestMultiSnakeMode(unittest.TestCase): + + def test_multi_snake_returns_valid_move(self): + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[ + [(2, 2), (2, 3), (2, 4)], + [(8, 8), (8, 7), (8, 6)], + ], + foods=[(3, 3), (7, 7)], + )) + self.assertIn(result, ("up", "down", "left", "right")) + + def test_multi_avoids_contested_h2h(self): + """Must not step into a square two equal-length enemies can both reach.""" + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[ + [(7, 5), (7, 4), (7, 3)], # equal length, head at (7,5) — (6,5) is contested + [(5, 8), (5, 9), (5, 10)], + ], + foods=[(0, 0)], + )) + self.assertNotEqual(result, "right") + + +# ── Tests: hazard ───────────────────────────────────────────────────────────── + +class TestHazard(unittest.TestCase): + + def test_reads_hazard_damage_from_ruleset(self): + """hazardDamagePerTurn must be read from ruleset.settings.""" + board = make_board(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + hazard_damage=22, + )) + self.assertEqual(board.snake_class._hazard_damage_per_turn(board), 22) + + def test_hazard_entry_penalizes_score(self): + """Moving into a hazard that was there last turn should score lower than a safe move.""" + snake = UltimateBattleSnake() + snake.game_board = make_board(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + hazards=[(6, 5)], # hazard to the right + hazard_damage=14, + )) + snake.previous_hazards = {(6, 5)} # was there last turn too + snake._enemy_dmaps = [] + snake._enemy_heads = [] + snake._base_blocked = set() + + score_right, _ = snake._score_move( + move="right", pos={"x": 6, "y": 5}, + my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}], + my_len=3, my_health=90, + other_snakes=[], food_set=set(), + hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1}, + previous_hazard_set={(6, 5)}, + is_constrictor=False, enemy_attack_map={}, + enemy_can_grow={}, total_occupancy=0.05, + width=11, height=11, deadline=None, + ) + score_up, _ = snake._score_move( + move="up", pos={"x": 5, "y": 6}, + my_body=[{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}], + my_len=3, my_health=90, + other_snakes=[], food_set=set(), + hazard_set={(6, 5)}, hazard_damage=14, hazard_count={(6, 5): 1}, + previous_hazard_set={(6, 5)}, + is_constrictor=False, enemy_attack_map={}, + enemy_can_grow={}, total_occupancy=0.05, + width=11, height=11, deadline=None, + ) + self.assertGreater(score_up, score_right) + + def test_hazard_will_kill_avoids_fatal_corridor(self): + """If the only exit from a hazard corridor costs more health than we have, it is fatal.""" + snake = UltimateBattleSnake() + # Hazard fills x=0..4, health=10, damage=14 — any step through kills us + hazard = {(x, y) for x in range(5) for y in range(11)} + blocked: set = set() + result = snake._hazard_will_kill( + point=(0, 5), hazard_set=hazard, hazard_count={}, blocked=blocked, + width=11, height=11, health=10, hazard_damage=14, + ) + self.assertTrue(result) + + def test_hazard_will_not_kill_when_exit_is_close(self): + """If exit is 1 step away and health > damage, it is survivable.""" + snake = UltimateBattleSnake() + hazard = {(5, 5)} # single hazard cell + blocked: set = set() + result = snake._hazard_will_kill( + point=(5, 5), hazard_set=hazard, hazard_count={}, blocked=blocked, + width=11, height=11, health=50, hazard_damage=14, + ) + self.assertFalse(result) + + +# ── Tests: enemy_can_grow bug fixes (B1/B2/B4) ─────────────────────────────── + +class TestEnemyCanGrow(unittest.TestCase): + + def test_b1_enemy_tail_stays_blocked_when_growing(self): + """B1: _simulation_blocked keeps enemy tail blocked when enemy_can_grow=True.""" + snake = UltimateBattleSnake() + future_body = [ + {"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3} + ] + enemy = { + "id": "e", "body": [ + {"x": 1, "y": 1}, {"x": 1, "y": 0}, + {"x": 0, "y": 0}, {"x": 0, "y": 1}, + ], + "head": {"x": 1, "y": 1}, + } + blocked = snake._simulation_blocked( + future_body=future_body, + other_snakes=[enemy], + food_set={(2, 1)}, # food adjacent to enemy head → enemy will grow + is_constrictor=False, + enemy_can_grow={"e": True}, + ) + self.assertIn((0, 1), blocked) # tail must remain blocked + + def test_b1_enemy_tail_vacates_when_not_growing(self): + """B1: _simulation_blocked removes enemy tail when enemy_can_grow=False.""" + snake = UltimateBattleSnake() + future_body = [ + {"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3} + ] + enemy = { + "id": "e", "body": [ + {"x": 1, "y": 1}, {"x": 1, "y": 0}, + {"x": 0, "y": 0}, {"x": 0, "y": 1}, + ], + "head": {"x": 1, "y": 1}, + } + blocked = snake._simulation_blocked( + future_body=future_body, + other_snakes=[enemy], + food_set=set(), # no food → enemy won't grow + is_constrictor=False, + enemy_can_grow={"e": False}, + ) + self.assertNotIn((0, 1), blocked) # tail must be free + + def test_b2_attack_map_blocks_enemy_tail_when_growing(self): + """B2: _build_enemy_attack_map marks enemy tail as NOT steppable when enemy can grow.""" + snake = UltimateBattleSnake() + my_snake = { + "id": "me", + "body": [{"x": 6, "y": 6}, {"x": 6, "y": 5}, {"x": 5, "y": 5}, {"x": 5, "y": 6}], + "head": {"x": 6, "y": 6}, + } + enemy = { + "id": "e", + "body": [ + {"x": 3, "y": 3}, {"x": 3, "y": 2}, + {"x": 2, "y": 2}, {"x": 2, "y": 3}, + ], + "head": {"x": 3, "y": 3}, + } + # Food at (4,3) — enemy head is adjacent, so enemy can grow → tail at (2,3) won't vacate + atk = snake._build_enemy_attack_map( + my_snake=my_snake, other_snakes=[enemy], + food_set={(4, 3)}, + is_constrictor=False, + width=11, height=11, + enemy_can_grow={"e": True}, + ) + # (2,3) is enemy tail — enemy moving there would require it to vacate, but it won't + self.assertIsNone(atk.get((2, 3))) + + def test_b4_choose_move_passes_enemy_can_grow_to_attack_map(self): + """B4: choose_move must pass enemy_can_grow so attack map is accurate.""" + # Regression: enemy adjacent to food, its tail should NOT appear in attack_map + result = move(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + other_bodies=[[(3, 3), (3, 2), (2, 2), (2, 3)]], + foods=[(4, 3)], # adjacent to enemy head → enemy can grow + )) + self.assertIn(result, ("up", "down", "left", "right")) + + +# ── Tests: minimax stacked tail (B6) ───────────────────────────────────────── + +class TestMinimaxStackedTail(unittest.TestCase): + + def test_b6_stacked_tail_not_treated_as_vacated_in_minimax(self): + """B6: _minimax_sim must keep stacked tails in occupancy.""" + snake = UltimateBattleSnake() + # Enemy has stacked tail (just ate) — body[-1] == body[-2] + my_body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + enemy_body = [ + {"x": 7, "y": 5}, {"x": 7, "y": 4}, + {"x": 7, "y": 3}, {"x": 7, "y": 3}, # stacked tail + ] + # The stacked tail is at (7,3) — it should remain in occupancy + my_occ = {(s["x"], s["y"]) for s in my_body} + if not snake._is_tail_stacked(my_body): + my_occ.discard((my_body[-1]["x"], my_body[-1]["y"])) + en_occ = {(s["x"], s["y"]) for s in enemy_body} + if not snake._is_tail_stacked(enemy_body): + en_occ.discard((enemy_body[-1]["x"], enemy_body[-1]["y"])) + + # Stacked tail IS stacked → must remain in en_occ + self.assertIn((7, 3), en_occ) + + def test_b6_non_stacked_tail_vacated_in_minimax(self): + """B6: Non-stacked tail must be removed from minimax occupancy.""" + snake = UltimateBattleSnake() + enemy_body = [ + {"x": 7, "y": 5}, {"x": 7, "y": 4}, + {"x": 7, "y": 3}, {"x": 6, "y": 3}, # normal tail at (6,3) + ] + en_occ = {(s["x"], s["y"]) for s in enemy_body} + if not snake._is_tail_stacked(enemy_body): + en_occ.discard((enemy_body[-1]["x"], enemy_body[-1]["y"])) + self.assertNotIn((6, 3), en_occ) + + +# ── Tests: articulation point ───────────────────────────────────────────────── + +class TestArticulationPenalty(unittest.TestCase): + + def test_no_penalty_when_not_cut_vertex(self): + """Open center of a board is not a cut vertex — penalty should be 0.""" + snake = UltimateBattleSnake() + penalty = snake._articulation_penalty( + point=(5, 5), blocked=set(), + width=11, height=11, required_space=3, + ) + self.assertEqual(penalty, 0.0) + + def test_severe_penalty_when_smallest_partition_too_small(self): + """Entering a cut vertex that traps us in a tiny partition gets 1500 penalty. + + Layout (3x3 board, P = point under test): + . X . + X P X ← only up/down are free; removing P splits top-cell from bottom-cell + . X . + """ + snake = UltimateBattleSnake() + # Block left, right and all four corners so (1,2) and (1,0) are isolated pockets + blocked = {(0, 1), (2, 1), (0, 0), (2, 0), (0, 2), (2, 2)} + penalty = snake._articulation_penalty( + point=(1, 1), blocked=blocked, + width=3, height=3, required_space=5, # both pockets have size 1 < 5 + ) + self.assertEqual(penalty, 1500.0) + + +# ── Tests: survival tree ────────────────────────────────────────────────────── + +class TestSurvivalTree(unittest.TestCase): + + def test_rollout_bonus_positive_for_open_position(self): + """Future rollout should return a positive bonus for an open board.""" + snake = UltimateBattleSnake() + snake._enemy_dmaps = [] + snake._enemy_heads = [] + my_body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + safe_moves = {"up": {"x": 5, "y": 6}, "left": {"x": 4, "y": 5}, "right": {"x": 6, "y": 5}} + bonus = snake._future_rollout_bonus( + move="up", + safe_moves=safe_moves, + my_body=my_body, + other_snakes=[], + food_set=set(), + is_constrictor=False, + width=11, height=11, + enemy_can_grow={}, + deadline=None, + ) + self.assertGreater(bonus, 0.0) + + def test_rollout_bonus_negative_for_trapped_position(self): + """Rollout into a tiny pocket should return a negative bonus.""" + snake = UltimateBattleSnake() + snake._enemy_dmaps = [] + snake._enemy_heads = [] + # Head at (0,0), body fills (0,1), (1,1), (1,0) — moving up leads to a 1-cell pocket + my_body = [ + {"x": 0, "y": 0}, {"x": 0, "y": 1}, + {"x": 1, "y": 1}, {"x": 1, "y": 0}, + ] + safe_moves = {"right": {"x": 1, "y": 0}} # only our tail square (will vacate) + # Use right if legal, else just test the tree score directly + sc = snake._future_position_score( + my_body=[ + {"x": 0, "y": 1}, {"x": 0, "y": 0}, + {"x": 1, "y": 1}, {"x": 1, "y": 0}, + ], # after moving up from (0,0) into a tiny space + other_snakes=[], + food_set=set(), + is_constrictor=False, + width=3, height=3, + enemy_can_grow={}, + deadline=None, + ) + self.assertLess(sc, 0.0) + + def test_death_veto_prevents_stepping_into_trap(self): + """DEATH_VETO must exclude moves the tree identifies as certainly fatal.""" + # Head trapped in a corner with only one exit that leads to a dead end + result = move(gs( + my_body=[(0, 1), (0, 2), (1, 2), (1, 1), (1, 0)], + other_bodies=[[(3, 3), (3, 4), (4, 4), (4, 3), (4, 2), (3, 2)]], + foods=[], + width=5, height=5, + )) + self.assertIn(result, ("up", "down", "left", "right")) + + +# ── Tests: territory / Voronoi ──────────────────────────────────────────────── + +class TestTerritory(unittest.TestCase): + + def test_territory_positive_when_alone(self): + """With no enemies, territory score should equal board cells (all ours).""" + snake = UltimateBattleSnake() + snake._enemy_heads = [] + snake._enemy_dmaps = [] + score = snake._territory_fast( + my_pos=(5, 5), blocked=set(), width=11, height=11, + ) + self.assertEqual(score, 0) # no enemies → returns 0 (no comparison possible) + + def test_territory_positive_when_closer_to_center(self): + """Snake closer to center should have more territory than enemy stuck in a corner.""" + snake = UltimateBattleSnake() + enemy_head = (0, 0) + snake._enemy_heads = [enemy_head] + snake._enemy_dmaps = [snake._distance_map(enemy_head, set(), 11, 11)] + score = snake._territory_fast( + my_pos=(5, 5), blocked=set(), width=11, height=11, + ) + self.assertGreater(score, 0) + + +# ── Tests: version ──────────────────────────────────────────────────────────── + +class TestVersion(unittest.TestCase): + + def test_version_matches_registry(self): + from snakes import SNAKE_REGISTRY, get_snake_version + self.assertEqual(UltimateBattleSnake.VERSION, "4.4.0") + self.assertEqual(get_snake_version("UltimateBattleSnake"), "4.4.0") + self.assertEqual(SNAKE_REGISTRY["UltimateBattleSnake"], "4.4.0") + + def test_instance_version_matches_class(self): + snake = UltimateBattleSnake() + self.assertEqual(snake.version, UltimateBattleSnake.VERSION) + + def test_builder_returns_ultimate_battle_snake(self): + from snakes import SnakeBuilder + snake = SnakeBuilder.build("UltimateBattleSnake") + self.assertIsInstance(snake, UltimateBattleSnake) + + +# ── Tests: C1 — Snail Mode hazard stack counting ───────────────────────────── + +class TestSnailModeHazardStack(unittest.TestCase): + + def _base_body(self): + return [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + + def _score(self, snake, pos, hazard_set, hazard_count, health=90): + snake._enemy_dmaps = [] + snake._enemy_heads = [] + snake._base_blocked = set() + sc, _ = snake._score_move( + move="right", pos=pos, + my_body=self._base_body(), my_len=3, my_health=health, + other_snakes=[], food_set=set(), + hazard_set=hazard_set, hazard_damage=14, hazard_count=hazard_count, + previous_hazard_set=hazard_set, + is_constrictor=False, enemy_attack_map={}, + enemy_can_grow={}, total_occupancy=0.05, + width=11, height=11, deadline=None, + ) + return sc + + def test_stacked_hazard_penalizes_more_than_single(self): + """C1: A tile with stack=3 must score lower than the same tile with stack=1.""" + snake = UltimateBattleSnake() + pos = {"x": 6, "y": 5} + pt = (6, 5) + score_single = self._score(snake, pos, {pt}, {pt: 1}) + score_triple = self._score(snake, pos, {pt}, {pt: 3}) + self.assertGreater(score_single, score_triple, + "triple-stacked hazard must score lower than single-stack") + + def test_single_stack_hazard_matches_baseline(self): + """C1: stack=1 in hazard_count should behave identically to no entry in dict.""" + snake = UltimateBattleSnake() + pos = {"x": 6, "y": 5} + pt = (6, 5) + score_explicit = self._score(snake, pos, {pt}, {pt: 1}) + score_default = self._score(snake, pos, {pt}, {}) + self.assertAlmostEqual(score_explicit, score_default, places=3, + msg="explicit stack=1 and implicit default should produce identical scores") + + def test_hazard_count_built_from_duplicate_hazard_coords(self): + """C1: hazard_count correctly counts multiple entries for the same tile.""" + snake = UltimateBattleSnake() + hazards = [{"x": 3, "y": 3}, {"x": 3, "y": 3}, {"x": 3, "y": 3}] + hazard_set = set() + hazard_count: dict = {} + for h in hazards: + pt = (h["x"], h["y"]) + hazard_set.add(pt) + hazard_count[pt] = hazard_count.get(pt, 0) + 1 + self.assertEqual(hazard_count.get((3, 3)), 3) + self.assertEqual(len(hazard_set), 1) + + +# ── Tests: C2 — food eaten on hazard tile → no minimax hazard penalty ──────── + +class TestHazardFoodMinimax(unittest.TestCase): + + def _make_body(self, coords): + return [{"x": x, "y": y} for x, y in coords] + + def test_c2_no_hazard_penalty_when_food_eaten(self): + """C2: eating food on a hazard tile must not subtract hazard health in minimax.""" + snake = UltimateBattleSnake() + my_body = self._make_body([(5, 5), (5, 4), (5, 3)]) + en_body = self._make_body([(9, 9), (9, 8), (9, 7)]) + food_set = {(6, 5)} # food at the hazard tile + hazard_set = {(6, 5)} # same tile is hazard + hazard_count = {(6, 5): 1} + + # Healthy snake, one-step minimax: eating the food should give health=100 + # C2 means: health after eating = 100 (food resets), no hazard deduction + # We can verify by checking the minimax with depth=1 doesn't die of hazard + val_with_food = snake._minimax_sim( + my_body=my_body, enemy_body=en_body, + food_set=food_set, hazard_set=hazard_set, + my_health=100, enemy_health=100, + hazard_damage=99, hazard_count=hazard_count, # damage=99 would kill if applied + width=11, height=11, depth=1, + alpha=-1e9, beta=1e9, deadline=None, + ) + # If C2 is correct, my snake isn't dead from hazard+food, so value should not be -3000 + self.assertGreater(val_with_food, -3000.0, + "C2: eating food on hazard must not apply hazard damage — snake should survive") + + def test_c2_score_move_no_health_penalty_with_food(self): + """C2: _score_move must not deduct hazard damage when food is eaten on the tile.""" + snake = UltimateBattleSnake() + snake._enemy_dmaps = [] + snake._enemy_heads = [] + snake._base_blocked = set() + body = self._make_body([(5, 5), (5, 4), (5, 3)]) + pt = (6, 5) + # Scenario: health=15, hazard_damage=99. Without C2, food+hazard would kill. + # With C2 fix, food resets health to 100 and hazard is NOT subtracted. + sc_food_on_hazard, _ = snake._score_move( + move="right", pos={"x": 6, "y": 5}, + my_body=body, my_len=3, my_health=15, + other_snakes=[], food_set={pt}, + hazard_set={pt}, hazard_damage=99, hazard_count={pt: 1}, + previous_hazard_set={pt}, + is_constrictor=False, enemy_attack_map={}, + enemy_can_grow={}, total_occupancy=0.05, + width=11, height=11, deadline=None, + ) + # Without food, same lethal hazard tile would cause health_after = 15-1-99 = -85 → -10000 + sc_no_food_on_hazard, _ = snake._score_move( + move="right", pos={"x": 6, "y": 5}, + my_body=body, my_len=3, my_health=15, + other_snakes=[], food_set=set(), + hazard_set={pt}, hazard_damage=99, hazard_count={pt: 1}, + previous_hazard_set={pt}, + is_constrictor=False, enemy_attack_map={}, + enemy_can_grow={}, total_occupancy=0.05, + width=11, height=11, deadline=None, + ) + self.assertGreater(sc_food_on_hazard, sc_no_food_on_hazard, + "C2: eating food on hazard must score much higher than entering same hazard without food") + + +# ── Tests: C3 — _hazard_will_kill includes baseline -1/turn ────────────────── + +class TestHazardWillKillBaseline(unittest.TestCase): + + def test_c3_baseline_included_borderline_case(self): + """C3: with old math (no baseline), health=15, damage=14, 1 step would survive. + With C3 fix (baseline included), 15 - 1*(1+14)=0 → fatal.""" + snake = UltimateBattleSnake() + hazard = {(5, 5)} + result = snake._hazard_will_kill( + point=(5, 5), hazard_set=hazard, hazard_count={}, + blocked=set(), width=11, height=11, + health=15, hazard_damage=14, + ) + self.assertTrue(result, + "C3: health=15 damage=14 → 15-(1+14)=0 → fatal (baseline -1/turn must be counted)") + + def test_c3_survives_when_health_exceeds_total_cost(self): + """C3: health=17, damage=14, 1-step exit → 17-(1+14)=2 > 0 → survivable.""" + snake = UltimateBattleSnake() + hazard = {(5, 5)} + result = snake._hazard_will_kill( + point=(5, 5), hazard_set=hazard, hazard_count={}, + blocked=set(), width=11, height=11, + health=17, hazard_damage=14, + ) + self.assertFalse(result, + "C3: health=17 damage=14 → 17-(1+14)=2 > 0 → should survive") + + def test_c3_stacked_hazard_kills_faster(self): + """C3+C1: stack=2 doubles per-step hazard cost, killing snakes that would survive stack=1.""" + snake = UltimateBattleSnake() + pt = (5, 5) + hazard = {pt} + # health=20, damage=14, stack=2 → per_step=1+14*2=29 → 20-29=-9 → fatal + result_stacked = snake._hazard_will_kill( + point=pt, hazard_set=hazard, hazard_count={pt: 2}, + blocked=set(), width=11, height=11, + health=20, hazard_damage=14, + ) + # Same health, stack=1 → per_step=15 → 20-15=5 → survivable + result_single = snake._hazard_will_kill( + point=pt, hazard_set=hazard, hazard_count={pt: 1}, + blocked=set(), width=11, height=11, + health=20, hazard_damage=14, + ) + self.assertTrue(result_stacked, "stack=2 should be fatal at health=20") + self.assertFalse(result_single, "stack=1 should be survivable at health=20") + + +# ── Tests: C4 — enemy tail vacate in _legal_moves ──────────────────────────── + +class TestEnemyTailVacate(unittest.TestCase): + + def test_c4_can_step_on_enemy_tail_when_not_growing(self): + """C4: _legal_moves allows moving onto enemy tail if enemy won't grow.""" + snake = UltimateBattleSnake() + # My head at (5,5), enemy tail at (4,5) — the only open direction besides (6,5) + my_body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + enemy = { + "id": "e", + "head": {"x": 4, "y": 6}, + "body": [{"x": 4, "y": 6}, {"x": 4, "y": 7}, {"x": 4, "y": 5}], # tail at (4,5) + } + my_head = my_body[0] + moves = snake._legal_moves( + my_head=my_head, my_body=my_body, other_snakes=[enemy], + food_set=set(), is_constrictor=False, width=11, height=11, + enemy_can_grow={"e": False}, # enemy won't grow + ) + # (4,5) is the enemy tail and should be accessible + accessible = {(v["x"], v["y"]) for v in moves.values()} + self.assertIn((4, 5), accessible, + "C4: enemy tail that will vacate must be a legal move") + + def test_c4_cannot_step_on_enemy_tail_when_growing(self): + """C4: enemy tail stays blocked when enemy is about to grow.""" + snake = UltimateBattleSnake() + my_body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + enemy = { + "id": "e", + "head": {"x": 4, "y": 6}, + "body": [{"x": 4, "y": 6}, {"x": 4, "y": 7}, {"x": 4, "y": 5}], + } + my_head = my_body[0] + moves = snake._legal_moves( + my_head=my_head, my_body=my_body, other_snakes=[enemy], + food_set=set(), is_constrictor=False, width=11, height=11, + enemy_can_grow={"e": True}, # enemy will grow — tail stays + ) + accessible = {(v["x"], v["y"]) for v in moves.values()} + self.assertNotIn((4, 5), accessible, + "C4: enemy tail that will NOT vacate must stay blocked") + + def test_c4_constrictor_enemy_tails_always_blocked(self): + """C4: in constrictor mode enemy tails never vacate (no growth/food mechanics).""" + snake = UltimateBattleSnake() + my_body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + enemy = { + "id": "e", + "head": {"x": 4, "y": 6}, + "body": [{"x": 4, "y": 6}, {"x": 4, "y": 7}, {"x": 4, "y": 5}], + } + my_head = my_body[0] + moves = snake._legal_moves( + my_head=my_head, my_body=my_body, other_snakes=[enemy], + food_set=set(), is_constrictor=True, width=11, height=11, + enemy_can_grow={"e": False}, + ) + accessible = {(v["x"], v["y"]) for v in moves.values()} + self.assertNotIn((4, 5), accessible, + "C4: constrictor mode — enemy tail always stays blocked") + + +# ── Tests: C5 — mode detection from ruleset + map ──────────────────────────── + +class TestModeDetection(unittest.TestCase): + + def test_c5_snail_map_detected(self): + """C5: game map 'snail_mode' must be recognised even when ruleset name is 'standard'.""" + board = make_board(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + game_type="standard", + game_map="snail_mode", + )) + snake = UltimateBattleSnake() + move = snake.choose_move(board) + self.assertIn(move, ("up", "down", "left", "right")) + + def test_c5_constrictor_ruleset_detected(self): + """C5: ruleset name 'constrictor' routes to constrictor mode handler.""" + board = make_board(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + game_type="constrictor", + )) + snake = UltimateBattleSnake() + move = snake.choose_move(board) + self.assertIn(move, ("up", "down", "left", "right")) + + +if __name__ == "__main__": + unittest.main()