1215 lines
47 KiB
Python
1215 lines
47 KiB
Python
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.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()
|
|
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"))
|
|
|
|
|
|
# ── 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()
|