From d4d6375e0177620c773c46dae1bebfe01d83f9e1 Mon Sep 17 00:00:00 2001 From: Daniel Dolezal Date: Tue, 3 Nov 2026 10:08:38 +0100 Subject: [PATCH] fix(master-snake): repair runtime integration and move safety - Read board state through the GameBoard interface used by the server. - Record one turn-indexed calculation history entry per move. - Try alternative food when nearer targets are blocked or unreachable. - Handle empty food lists without relying on exception fallback. - Normalize flood-fill coordinates and count reachable cells correctly. - Reject blocked destinations before evaluating escape space. - Prefer legal fallback moves when no region meets the space threshold. - Remove a distance tie-breaker that mistook free cells for snake bodies. - Align stale tests with current methods and tuple-based path results. - Cover runtime moves, food fallback, coordinate order, and dead ends. --- snakes/legacy/MasterSnake.py | 128 +++++++++++++++-------------------- tests/test_MasterSnake.py | 67 +++++++++++++++--- 2 files changed, 110 insertions(+), 85 deletions(-) diff --git a/snakes/legacy/MasterSnake.py b/snakes/legacy/MasterSnake.py index 6ca6385..4443e6e 100644 --- a/snakes/legacy/MasterSnake.py +++ b/snakes/legacy/MasterSnake.py @@ -35,7 +35,12 @@ class MasterSnake(TemplateSnake): safe_positions.append({'x': x, 'y': y}) return safe_positions - def choose_move(self, game_data): + def choose_move(self, game_board): + self.game_board = game_board + game_data = { + 'board': game_board.get_game_board_as_dict(), + 'you': game_board.get_my_snake(), + } board_width = game_data['board']['width'] board_height = game_data['board']['height'] snakes = game_data['board']['snakes'] @@ -45,25 +50,11 @@ class MasterSnake(TemplateSnake): # Vermeide Schlangenkörper safe_positions = self.avoid_snake_body(snakes, board_width, board_height) - # Finde die nächstgelegene Nahrungsquelle, wenn Nahrung vorhanden ist - try: - if self.is_food_nearby(my_head, game_data['board']['food']) or self.disabled_find_near_by_food: - path_to_food = self.find_path_to_food(game_data) - if path_to_food: - # Implementiere Logik, um in Richtung der Nahrungsquelle zu bewegen, falls sicher - move = self.move_towards(my_head, path_to_food[0], safe_positions) - self.add_to_history({"my_head": my_head, "path_to_food": path_to_food, "move": move}) - else: - # Einfache Logik, um eine Bewegungsrichtung zu wählen, wenn keine Nahrung vorhanden ist - move = self.find_direction(my_head, safe_positions) - self.add_to_history({"my_head": my_head, "move": move}) - else: - # Wenn keine Nahrung in der Nähe ist, bewege dich in eine Richtung, die dich nahe an deinem eigenen Körper hält - move = self.find_direction(my_head, safe_positions) - self.add_to_history({"my_head": my_head, "move": move}) - except ValueError: - move = self.find_direction(my_head, safe_positions) - self.add_to_history({"my_head": my_head, "move": move}) + move = self.find_direction(my_head, safe_positions) + if self.is_food_nearby(my_head, game_data['board']['food']) or self.disabled_find_near_by_food: + path_to_food = self.find_path_to_food(game_data) + if path_to_food: + move = self.move_towards(my_head, path_to_food[0], safe_positions) # Finde den größten sicheren Bereich max_area_start, max_area = self.flood_fill(my_head, safe_positions) @@ -74,7 +65,10 @@ class MasterSnake(TemplateSnake): # Überprüfe zukünftige Bewegungen, um Sackgassen zu vermeiden move = self.avoid_dead_ends(my_head, move, safe_positions, snakes) - self.add_to_history({"my_head": my_head, "move": move}) + self.add_to_history({ + "turn": game_board.get_turn(), + "data": [{"function": "choose_move", "my_head": my_head, "move": move}], + }) return move @@ -82,18 +76,14 @@ class MasterSnake(TemplateSnake): directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)} best_direction = None min_distance = float('inf') - min_distance_to_body = float('inf') - body_positions = set((pos['x'], pos['y']) for pos in safe_positions[:-1]) # Exclude the head from body positions for direction, (dx, dy) in directions.items(): next_position = {'x': head['x'] + dx, 'y': head['y'] + dy} if next_position in safe_positions: distance = abs(target[0] - next_position['x']) + abs(target[1] - next_position['y']) - distance_to_body = sum(abs(part[0] - next_position['x']) + abs(part[1] - next_position['y']) for part in body_positions) - if distance < min_distance or (distance == min_distance and distance_to_body < min_distance_to_body): + if distance < min_distance: best_direction = direction min_distance = distance - min_distance_to_body = distance_to_body return best_direction if best_direction else "up" # Default to moving up if no safe direction found @@ -113,12 +103,15 @@ class MasterSnake(TemplateSnake): for part in snake['body']: obstacles.add((part['x'], part['y'])) - # Choose the closest food source based on the heuristic - closest_food = min(food_positions, key=lambda food: abs(food['x'] - my_head['x']) + abs(food['y'] - my_head['y'])) - - # Use A* to search for a safe path - path = self.a_star_search(my_head, closest_food, obstacles, board_width, board_height) - return path + # Skip blocked or unreachable food instead of giving up on all food. + for food in sorted( + food_positions, + key=lambda food: abs(food['x'] - my_head['x']) + abs(food['y'] - my_head['y']), + ): + path = self.a_star_search(my_head, food, obstacles, board_width, board_height) + if path: + return path + return None def a_star_search(self, start, goal, obstacles, board_width, board_height): # Convert snake positions into a set of obstacles @@ -182,17 +175,15 @@ class MasterSnake(TemplateSnake): def avoid_dead_ends(self, head, move, safe_positions, snakes): directions = {'up': (0, 1), 'down': (0, -1), 'left': (-1, 0), 'right': (1, 0)} - dx, dy = directions[move] - future_head = {'x': head['x'] + dx, 'y': head['y'] + dy} - body_positions = set((part['x'], part['y']) for part in snakes[0]['body']) + candidates = [move] + [direction for direction in directions if direction != move] + for direction in candidates: + dx, dy = directions[direction] + future_head = {'x': head['x'] + dx, 'y': head['y'] + dy} + if self.is_future_move_safe(future_head, safe_positions, snakes): + return direction - if not self.is_future_move_safe(future_head, safe_positions, snakes) or not self.avoid_self_collision(future_head, body_positions): - for alternative_move in directions.keys(): - dx, dy = directions[alternative_move] - alternative_future_head = {'x': head['x'] + dx, 'y': head['y'] + dy} - if self.is_future_move_safe(alternative_future_head, safe_positions, snakes) and self.avoid_self_collision(alternative_future_head, body_positions): - return alternative_move - return move + # When every region is too small, still prefer a legal move to a collision. + return self.find_direction(head, safe_positions) def simulate_snake_movement(self, snakes): future_body_positions = set() @@ -209,41 +200,30 @@ class MasterSnake(TemplateSnake): safe_positions_set = set((pos['x'], pos['y']) for pos in safe_positions) # Entferne die zukünftigen Körperpositionen aus den sicheren Positionen safe_positions_set = safe_positions_set - future_body_positions - # Füge die zukünftige Kopfposition hinzu, um sie als Startpunkt zu verwenden - safe_positions_set.add((future_head['x'], future_head['y'])) - # Berechne die Anzahl der erreichbaren sicheren Positionen von der zukünftigen Kopfposition aus - reachable_positions = self.flood_fill((future_head['x'], future_head['y']), safe_positions_set) - # Entscheide, ob die Bewegung sicher ist, basierend auf der Anzahl der erreichbaren Positionen - - fill_bool = len(reachable_positions) > len(safe_positions_set) * 0.25 - if fill_bool: - return fill_bool - - return len(safe_positions_set) >= len(snakes[0]['body']) + start = (future_head['x'], future_head['y']) + if start not in safe_positions_set: + return False + _, reachable_positions = self.flood_fill(start, safe_positions_set) + return len(reachable_positions) >= max( + (len(snake['body']) for snake in snakes), default=1, + ) def flood_fill(self, start, safe_positions): + start = (start['x'], start['y']) if isinstance(start, dict) else tuple(start) + allowed = { + (position['x'], position['y']) if isinstance(position, dict) else tuple(position) + for position in safe_positions + } + # The current head may be occupied; it is only a traversal origin. stack = [start] visited = set() - max_area = 0 - max_area_start = None - while stack: position = stack.pop() - if isinstance(position, dict): - position = tuple(position.values()) - else: - position = tuple(position) - - if position not in visited: - visited.add(position) - for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]: # links, rechts, oben, unten - next_position = tuple([position[0] + dx, position[1] + dy]) - if next_position in safe_positions: - stack.append(next_position) - - # Überprüfe, ob der aktuelle Bereich größer ist als der bisher größte Bereich - if len(visited) > max_area: - max_area = len(visited) - max_area_start = position - - return max_area_start, visited + if position in visited: + continue + visited.add(position) + for dx, dy in ((-1, 0), (1, 0), (0, -1), (0, 1)): + neighbor = (position[0] + dx, position[1] + dy) + if neighbor in allowed and neighbor not in visited: + stack.append(neighbor) + return start, visited diff --git a/tests/test_MasterSnake.py b/tests/test_MasterSnake.py index 61e408d..5c3b977 100644 --- a/tests/test_MasterSnake.py +++ b/tests/test_MasterSnake.py @@ -1,5 +1,6 @@ import unittest from snakes.legacy.MasterSnake import MasterSnake +from server.GameBoard import GameBoard class TestMasterSnake(unittest.TestCase): def setUp(self): @@ -71,22 +72,68 @@ class TestMasterSnake(unittest.TestCase): self.assertIn({'x': 10, 'y': 10}, safe_positions) def test_choose_move(self): - move = self.snake.choose_move(self.game_data) - self.assertIn(move, ["up", "down", "left", "right"]) + board = GameBoard( + game_id='test-game', width=11, height=11, + ruleset={'name': 'standard', 'version': 'v1'}, + source='test', map='standard', snake_class=self.snake, + ) + state = {**self.game_data, 'turn': 0} + state['board'] = {**state['board'], 'hazards': []} + board.read_game_data(state) + move = board.snake_neat_make_a_move() + self.assertEqual(move, 'up') + self.assertEqual(len(self.snake.get_history()), 1) + self.assertEqual(self.snake.get_history()[0]['turn'], 0) + self.assertIsInstance(self.snake.get_history()[0]['data'], list) - def test_move_towards_food(self): + def test_move_towards(self): head = {'x': 0, 'y': 0} food = (5, 5) safe_positions = [{'x': 1, 'y': 0}, {'x': 0, 'y': 1}, {'x': 1, 'y': 1}] - direction = self.snake.move_towards_food(head, food, safe_positions) + direction = self.snake.move_towards(head, food, safe_positions) self.assertIn(direction, ["up", "down", "left", "right"]) def test_find_path_to_food(self): path = self.snake.find_path_to_food(self.game_data) - print(path) self.assertIsInstance(path, list) - self.assertTrue(all(isinstance(pos, dict) for pos in path)) - self.assertTrue(all('x' in pos and 'y' in pos for pos in path)) + self.assertEqual(path[-1], (5, 5)) + obstacles = { + (part['x'], part['y']) + for snake in self.game_data['board']['snakes'] + for part in snake['body'] + } + previous = (0, 0) + for position in path: + self.assertNotIn(position, obstacles) + self.assertEqual(abs(position[0] - previous[0]) + abs(position[1] - previous[1]), 1) + previous = position + + def test_no_reachable_food(self): + self.game_data['board']['food'] = [] + self.assertIsNone(self.snake.find_path_to_food(self.game_data)) + self.game_data['board']['food'] = [{'x': 1, 'y': 0}] + self.assertIsNone(self.snake.find_path_to_food(self.game_data)) + + def test_flood_fill_normalizes_coordinate_dicts(self): + start, reachable = self.snake.flood_fill( + {'y': 0, 'x': 0}, + [{'x': 0, 'y': 1}, {'x': 0, 'y': 2}, {'x': 4, 'y': 4}], + ) + self.assertEqual(start, (0, 0)) + self.assertEqual(reachable, {(0, 0), (0, 1), (0, 2)}) + + def test_future_move_rejects_walls_bodies_and_small_regions(self): + snakes = [{'body': [{'x': 0, 'y': 0}, {'x': 1, 'y': 0}, {'x': 2, 'y': 0}]}] + safe = [{'x': 0, 'y': 1}, {'x': 0, 'y': 2}, {'x': 4, 'y': 4}] + for position in ({'x': -1, 'y': 0}, {'x': 1, 'y': 0}, {'x': 4, 'y': 4}): + with self.subTest(position=position): + self.assertFalse(self.snake.is_future_move_safe(position, safe, snakes)) + + def test_avoid_dead_ends_prefers_connected_escape_region(self): + head = {'x': 2, 'y': 2} + safe = [{'x': 3, 'y': 2}, {'x': 2, 'y': 3}, {'x': 2, 'y': 4}, {'x': 2, 'y': 5}] + snakes = [{'body': [{'x': 2, 'y': 2}, {'x': 2, 'y': 1}, {'x': 2, 'y': 0}]}] + self.assertEqual(self.snake.avoid_dead_ends(head, 'right', safe, snakes), 'up') def test_find_direction(self): head = {'x': 0, 'y': 0} @@ -97,12 +144,10 @@ class TestMasterSnake(unittest.TestCase): def test_avoid_dead_ends(self): head = {'x': 0, 'y': 0} move = "right" - board_width = 11 - board_height = 11 snakes = self.game_data['board']['snakes'] safe_positions = [{'x': 1, 'y': 0}, {'x': 0, 'y': 1}, {'x': 1, 'y': 1}] - new_move = self.snake.avoid_dead_ends(head, move, safe_positions, board_width, board_height, snakes) - self.assertIn(new_move, ["up", "down", "left", "right"]) + new_move = self.snake.avoid_dead_ends(head, move, safe_positions, snakes) + self.assertEqual(new_move, 'up') if __name__ == '__main__': unittest.main()