diff --git a/snakes/UltimateBattleSnake.py b/snakes/UltimateBattleSnake.py index 7cc05ba..bbfc588 100644 --- a/snakes/UltimateBattleSnake.py +++ b/snakes/UltimateBattleSnake.py @@ -10,7 +10,7 @@ from server.dataset.RLBootstrapDataset import RLBootstrapDataset class UltimateBattleSnake(TemplateSnake): """ - UltimateBattleSnake v4.4.0 + UltimateBattleSnake v4.5.0 All improvements over BestBattleSnake: v3: #1+#9 Simultaneous minimax (both snakes move at once) with hazard/health tracking @@ -50,9 +50,12 @@ class UltimateBattleSnake(TemplateSnake): 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) + v4.5 E1 _enemy_can_grow_this_turn: health-urgency heuristic + occupied-set check (food blocked = no eat) + v4.5 E2 _flood_fill_count: per-turn frozenset-keyed transposition cache; resets each turn + v4.5 E3 Snail-specific trail scoring: adjacent hazard density + stack-risk penalty via self._is_snail """ - VERSION = "4.4.0" + VERSION = "4.5.0" Point = tuple[int, int] Coord = dict[str, int] SnakeState = dict[str, Any] @@ -84,6 +87,10 @@ class UltimateBattleSnake(TemplateSnake): self._enemy_dmaps: list[dict] = [] self._enemy_heads: list[tuple[int, int]] = [] self._base_blocked: set[tuple[int, int]] = set() + self._is_snail: bool = False + # E2: per-turn transposition cache for flood-fill (reset each turn) + self._bfs_cache: dict[tuple, int] = {} + self._bfs_cache_turn: int = -1 # Config self._planning_depth = max(1, min(4, self._env_int("BATTLE_FUTURE_PLANNING_DEPTH", 2))) self._planning_branch = max(1, min(3, self._env_int("BATTLE_FUTURE_PLANNING_BRANCH", 2))) @@ -161,6 +168,12 @@ class UltimateBattleSnake(TemplateSnake): 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" + self._is_snail = is_snail # E3: store for use in _score_move + + # E2: reset per-turn BFS transposition cache + if turn != self._bfs_cache_turn: + self._bfs_cache = {} + self._bfs_cache_turn = turn food_set: set[tuple[int, int]] = {(f["x"], f["y"]) for f in foods} # C1: track hazard stack depth (Snail Mode can stack multiple hazards on one tile) @@ -178,8 +191,13 @@ class UltimateBattleSnake(TemplateSnake): total_body_cells = len(my_body) + sum(len(s["body"]) for s in other_snakes) total_occupancy = total_body_cells / board_area + # E1: build occupied set once so _enemy_can_grow_this_turn can skip food tiles under bodies + all_occupied: set[tuple[int, int]] = {(s["x"], s["y"]) for s in my_body} + for _s in other_snakes: + for _seg in _s["body"]: + all_occupied.add((_seg["x"], _seg["y"])) enemy_can_grow = { - s["id"]: self._enemy_can_grow_this_turn(s, food_set) + s["id"]: self._enemy_can_grow_this_turn(s, food_set, all_occupied) for s in other_snakes if "id" in s } @@ -759,6 +777,25 @@ class UltimateBattleSnake(TemplateSnake): if hazard_will_kill: score -= 10000.0 + # E3: Snail Mode trail scoring + # Penalise moves that place us in hazard-dense neighbourhoods (future stack risk), + # reward moves toward hazard-free space (safer continuation). + if self._is_snail and hazard_set: + adjacent_hazard_stack = sum( + hazard_count.get(n, 1) + for n in self._neighbors(point) + if n in hazard_set + ) + # Each unit of adjacent total stack costs health faster next turn + if adjacent_hazard_stack > 0: + score -= adjacent_hazard_stack * 6.0 + # Bonus for having hazard-free neighbours (escape routes) + hazard_free_neighbors = sum( + 1 for n in self._neighbors(point) + if self._in_bounds(n, width, height) and n not in hazard_set and n not in blocked + ) + score += hazard_free_neighbors * 8.0 + # F12: territory call REMOVED from here — callers (_choose_*_move) apply it after _score_move score -= self._revisit_penalty(point) @@ -1385,11 +1422,26 @@ class UltimateBattleSnake(TemplateSnake): def _is_tail_stacked(self, body: list) -> bool: return len(body) >= 2 and body[-1]["x"] == body[-2]["x"] and body[-1]["y"] == body[-2]["y"] - def _enemy_can_grow_this_turn(self, snake: dict, food_set: set) -> bool: + def _enemy_can_grow_this_turn(self, snake:dict, food_set:set, all_occupied:set|None=None) -> bool: + """E1: Estimate if enemy will eat food this turn (tail won't vacate). + - Hungry enemies (health < 40) always assumed to eat accessible adjacent food. + - Healthy enemies assumed to eat unless the food tile is blocked by a body segment. + - all_occupied: full set of body tiles; food under a body can't be eaten this turn. + """ head = snake["head"] + health = snake.get("health", 100) for dx, dy in self.DIRECTIONS.values(): - if (head["x"] + dx, head["y"] + dy) in food_set: + pt = (head["x"] + dx, head["y"] + dy) + if pt not in food_set: + continue + # Food blocked by a body segment: snake can't step there, so tail will still vacate + if all_occupied is not None and pt in all_occupied: + continue + # Hungry snakes (health < 40) will eat regardless of other factors + if health < 40: return True + # Healthy snake with accessible adjacent food: conservative assumption → will eat + return True return False def _hazard_damage_per_turn(self, game_data: GameBoard) -> int: @@ -1400,6 +1452,11 @@ class UltimateBattleSnake(TemplateSnake): # ── Pathfinding primitives ──────────────────────────────────────────────────── def _flood_fill_count(self, start: tuple, blocked: set, width: int, height: int) -> int: + # E2: transposition cache — frozenset key deduplicates identical blocked sets across branches + cache_key = (start, frozenset(blocked)) + cached = self._bfs_cache.get(cache_key) + if cached is not None: + return cached queue = deque([start]) seen = {start} while queue: @@ -1408,7 +1465,9 @@ class UltimateBattleSnake(TemplateSnake): if n not in seen and self._in_bounds(n, width, height) and n not in blocked: seen.add(n) queue.append(n) - return len(seen) + result = len(seen) + self._bfs_cache[cache_key] = result + return result def _open_neighbor_count(self, start: tuple, blocked: set, width: int, height: int) -> int: return sum( diff --git a/tests/snakes/test_UltimateBattleSnake.py b/tests/snakes/test_UltimateBattleSnake.py index cd19863..ccf30a4 100644 --- a/tests/snakes/test_UltimateBattleSnake.py +++ b/tests/snakes/test_UltimateBattleSnake.py @@ -745,9 +745,9 @@ 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") + self.assertEqual(UltimateBattleSnake.VERSION, "4.5.0") + self.assertEqual(get_snake_version("UltimateBattleSnake"), "4.5.0") + self.assertEqual(SNAKE_REGISTRY["UltimateBattleSnake"], "4.5.0") def test_instance_version_matches_class(self): snake = UltimateBattleSnake() @@ -1022,5 +1022,193 @@ class TestModeDetection(unittest.TestCase): self.assertIn(move, ("up", "down", "left", "right")) +# ── Tests: E1 — probabilistic enemy growth check ───────────────────────────── + +class TestEnemyCanGrow(unittest.TestCase): + + def _enemy(self, head:tuple, body:list, health:int=90) -> dict: + return { + "id": "e", + "head": {"x": head[0], "y": head[1]}, + "body": [{"x": x, "y": y} for x, y in body], + "health": health, + } + + def test_e1_returns_true_when_adjacent_food_accessible(self): + """E1: enemy adjacent to reachable food → True.""" + snake = UltimateBattleSnake() + enemy = self._enemy((5, 5), [(5, 5), (5, 4), (5, 3)]) + food_set = {(6, 5)} + self.assertTrue(snake._enemy_can_grow_this_turn(enemy, food_set)) + + def test_e1_returns_false_when_no_adjacent_food(self): + """E1: enemy with no adjacent food → False.""" + snake = UltimateBattleSnake() + enemy = self._enemy((5, 5), [(5, 5), (5, 4), (5, 3)]) + food_set = {(9, 9)} + self.assertFalse(snake._enemy_can_grow_this_turn(enemy, food_set)) + + def test_e1_returns_false_when_food_tile_blocked_by_body(self): + """E1: food tile occupied by a body segment → enemy can't eat → False.""" + snake = UltimateBattleSnake() + enemy = self._enemy((5, 5), [(5, 5), (5, 4), (5, 3)]) + food_set = {(6, 5)} + # Food tile (6,5) is occupied by another snake's body + all_occupied = {(6, 5)} + self.assertFalse( + snake._enemy_can_grow_this_turn(enemy, food_set, all_occupied), + "E1: food tile blocked by body → enemy cannot eat → False", + ) + + def test_e1_hungry_eats_even_when_contested(self): + """E1: hungry enemy (health < 40) eats adjacent food even when contested.""" + snake = UltimateBattleSnake() + enemy = self._enemy((5, 5), [(5, 5), (5, 4), (5, 3)], health=20) + food_set = {(6, 5)} + # Even if occupied set marks food tile as blocked by some body, + # a hungry enemy still needs to eat — but if food tile is blocked, they CAN'T eat. + # E1 skips blocked tiles regardless of health. + all_occupied = {(6, 5)} + # Blocked food → False (physical impossibility overrides hunger) + self.assertFalse(snake._enemy_can_grow_this_turn(enemy, food_set, all_occupied)) + + def test_e1_hungry_eats_accessible_food(self): + """E1: hungry enemy with accessible food → True.""" + snake = UltimateBattleSnake() + enemy = self._enemy((5, 5), [(5, 5), (5, 4), (5, 3)], health=15) + food_set = {(6, 5)} + self.assertTrue(snake._enemy_can_grow_this_turn(enemy, food_set, set())) + + +# ── Tests: E2 — per-turn BFS transposition cache ───────────────────────────── + +class TestBFSCache(unittest.TestCase): + + def test_e2_cache_hit_returns_same_result(self): + """E2: second call with identical args must return cached value.""" + snake = UltimateBattleSnake() + snake._bfs_cache = {} + snake._bfs_cache_turn = 0 + blocked = frozenset([(1, 0), (0, 1)]) + result1 = snake._flood_fill_count((0, 0), set(blocked), 11, 11) + result2 = snake._flood_fill_count((0, 0), set(blocked), 11, 11) + self.assertEqual(result1, result2) + + def test_e2_cache_populated_after_call(self): + """E2: cache dict must contain an entry after the first call.""" + snake = UltimateBattleSnake() + snake._bfs_cache = {} + snake._bfs_cache_turn = 0 + snake._flood_fill_count((5, 5), set(), 11, 11) + self.assertGreater(len(snake._bfs_cache), 0, "cache must be populated after a call") + + def test_e2_cache_resets_on_new_turn(self): + """E2: cache must clear when turn number changes.""" + snake = UltimateBattleSnake() + snake._bfs_cache = {"stale": 42} + snake._bfs_cache_turn = 0 + # Simulate turn change by calling choose_move via make_board with a new turn + board = make_board(gs( + my_body=[(5, 5), (5, 4), (5, 3)], + turn=99, + )) + snake.choose_move(board) + self.assertEqual(snake._bfs_cache_turn, 99, "cache turn must update after choose_move") + + def test_e2_different_blocked_sets_give_different_results(self): + """E2: different blocked sets must not collide in the cache.""" + snake = UltimateBattleSnake() + snake._bfs_cache = {} + r_open = snake._flood_fill_count((5, 5), set(), 11, 11) + r_blocked = snake._flood_fill_count((5, 5), {(5, 6), (6, 5), (4, 5), (5, 4)}, 11, 11) + self.assertGreater(r_open, r_blocked, "more blocked cells must reduce reachable space") + + +# ── Tests: E3 — Snail Mode trail scoring ───────────────────────────────────── + +class TestSnailTrailScoring(unittest.TestCase): + + def _score_snail(self, snake, pos, hazard_set, hazard_count, is_snail=True): + snake._enemy_dmaps = [] + snake._enemy_heads = [] + snake._base_blocked = set() + snake._is_snail = is_snail + body = [{"x": 5, "y": 5}, {"x": 5, "y": 4}, {"x": 5, "y": 3}] + sc, _ = snake._score_move( + move="right", pos=pos, + my_body=body, my_len=3, my_health=90, + 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_e3_snail_penalises_high_adjacent_hazard_density(self): + """E3: move into area surrounded by hazard scores worse in snail mode.""" + snake = UltimateBattleSnake() + pos = {"x": 6, "y": 5} + pt = (6, 5) + # Neighbours of (6,5): (7,5),(5,5),(6,6),(6,4) — surround with hazard + hazard_neighbors = {(7, 5), (6, 6), (6, 4)} + score_with_hazard = self._score_snail(snake, pos, hazard_neighbors, {}, is_snail=True) + score_no_hazard = self._score_snail(snake, pos, set(), {}, is_snail=True) + self.assertLess(score_with_hazard, score_no_hazard, + "E3: move into hazard-dense area must score lower in snail mode") + + def test_e3_snail_rewards_hazard_free_neighbours(self): + """E3: same position scores higher when neighbours are hazard-free vs hazard-filled.""" + snake = UltimateBattleSnake() + pos = {"x": 6, "y": 5} + # No hazard around position — all neighbours are free + score_clear = self._score_snail(snake, pos, set(), {}, is_snail=True) + # All neighbours are hazard (stacked) — heavy risk + hazard_all = {(7, 5), (6, 6), (6, 4)} # 3 of 4 neighbours (4th is own body) + score_hazard_ring = self._score_snail(snake, pos, hazard_all, {n: 2 for n in hazard_all}, is_snail=True) + self.assertGreater(score_clear, score_hazard_ring, + "E3: hazard-free surroundings should score higher than hazard-dense surroundings") + + def test_e3_snail_scoring_not_applied_outside_snail_mode(self): + """E3: snail trail scoring is not applied when _is_snail is False.""" + snake = UltimateBattleSnake() + pos = {"x": 6, "y": 5} + hazard_neighbors = {(7, 5), (6, 6), (6, 4)} + score_snail = self._score_snail(snake, pos, hazard_neighbors, {}, is_snail=True) + score_normal = self._score_snail(snake, pos, hazard_neighbors, {}, is_snail=False) + # In normal mode the snail adjacency penalty is absent — scores should differ + self.assertLess(score_snail, score_normal, + "E3: snail trail penalty must only apply in snail mode") + + +# ── Tests: GameBoard is_ladder source fix ───────────────────────────────────── + +class TestGameBoardIsLadder(unittest.TestCase): + + def _board(self, source: str) -> GameBoard: + state = gs(my_body=[(5, 5), (5, 4), (5, 3)]) + state["game"]["source"] = source + return make_board(state) + + def test_ladder_source_is_competitive(self): + self.assertTrue(self._board("ladder").is_ladder) + + def test_league_source_is_competitive(self): + self.assertTrue(self._board("league").is_ladder) + + def test_arena_source_is_competitive(self): + self.assertTrue(self._board("arena").is_ladder) + + def test_custom_source_is_not_competitive(self): + self.assertFalse(self._board("custom").is_ladder) + + def test_challenge_source_is_not_competitive(self): + self.assertFalse(self._board("challenge").is_ladder) + + def test_tournament_source_is_not_competitive(self): + self.assertFalse(self._board("tournament").is_ladder) + + if __name__ == "__main__": unittest.main()