amogus/tests/test_vision_raycast.py

329 lines
12 KiB
Python

"""
Tests for ray-traced vision system.
"""
import unittest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from src.engine.types import Position, Wall, Player, Role
from src.engine.vision_raycast import RaycastVision, VisibilityResult
class TestPosition(unittest.TestCase):
"""Tests for pixel-based Position class."""
def test_distance_to(self):
"""Test distance calculation."""
p1 = Position(x=0, y=0)
p2 = Position(x=3, y=4)
self.assertAlmostEqual(p1.distance_to(p2), 5.0)
def test_direction_to(self):
"""Test direction vector."""
p1 = Position(x=0, y=0)
p2 = Position(x=10, y=0)
dx, dy = p1.direction_to(p2)
self.assertAlmostEqual(dx, 1.0)
self.assertAlmostEqual(dy, 0.0)
def test_move_toward(self):
"""Test moving toward a target."""
p1 = Position(x=0, y=0)
p2 = Position(x=10, y=0)
p3 = p1.move_toward(p2, 5.0)
self.assertAlmostEqual(p3.x, 5.0)
self.assertAlmostEqual(p3.y, 0.0)
def test_to_tuple(self):
"""Test tuple conversion."""
p = Position(x=100, y=200)
self.assertEqual(p.to_tuple(), (100, 200))
def test_from_tuple(self):
"""Test tuple construction."""
p = Position.from_tuple((100, 200))
self.assertEqual(p.x, 100)
self.assertEqual(p.y, 200)
class TestWall(unittest.TestCase):
"""Tests for Wall class."""
def test_to_dict(self):
"""Test serialization."""
wall = Wall(p1=(0, 0), p2=(100, 0))
d = wall.to_dict()
self.assertEqual(d["p1"], [0, 0])
self.assertEqual(d["p2"], [100, 0])
def test_from_dict(self):
"""Test deserialization."""
wall = Wall.from_dict({"p1": [0, 0], "p2": [100, 0]})
self.assertEqual(wall.p1, (0, 0))
self.assertEqual(wall.p2, (100, 0))
class TestRaycastVision(unittest.TestCase):
"""Tests for ray-traced vision system."""
def test_no_walls_visible(self):
"""With no walls, everything in range is visible."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertTrue(result.visible)
self.assertAlmostEqual(result.distance, 100.0)
def test_beyond_radius_not_visible(self):
"""Targets beyond vision radius are not visible."""
vision = RaycastVision(walls=[], base_vision_radius=100)
observer = Position(x=0, y=0)
target = Position(x=200, y=0)
result = vision.check_visibility(observer, target)
self.assertFalse(result.visible)
def test_wall_blocks_vision(self):
"""Wall between observer and target blocks vision."""
# Wall at x=50, from y=-100 to y=100
wall = Wall(p1=(50, -100), p2=(50, 100))
vision = RaycastVision(walls=[wall], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertFalse(result.visible)
self.assertTrue(result.blocked_by_wall)
def test_wall_not_in_path(self):
"""Wall not in line of sight doesn't block."""
# Wall off to the side
wall = Wall(p1=(100, 100), p2=(100, 200))
vision = RaycastVision(walls=[wall], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertTrue(result.visible)
def test_get_visible_players(self):
"""Test getting list of visible players."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
close_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0))
far_player = Player(id="green", name="Green", color="green",
position=Position(x=500, y=0))
all_players = [observer, close_player, far_player]
visible = vision.get_visible_players(observer, all_players)
# Should see blue but not green
self.assertEqual(len(visible), 1)
self.assertEqual(visible[0][0].id, "blue")
def test_dead_players_not_visible_by_default(self):
"""Dead players are not visible by default."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
dead_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0), is_alive=False)
visible = vision.get_visible_players(observer, [observer, dead_player])
self.assertEqual(len(visible), 0)
def test_dead_players_visible_when_requested(self):
"""Dead players visible when include_dead=True."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
dead_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0), is_alive=False)
visible = vision.get_visible_players(observer, [observer, dead_player], include_dead=True)
self.assertEqual(len(visible), 1)
class TestLineIntersection(unittest.TestCase):
"""Tests for line segment intersection math."""
def setUp(self):
self.vision = RaycastVision(walls=[], base_vision_radius=300)
def test_crossing_lines_intersect(self):
"""Two crossing lines have an intersection."""
result = self.vision.line_segment_intersection(
(0, 0), (10, 10), # Diagonal line
(0, 10), (10, 0) # Crossing diagonal
)
self.assertIsNotNone(result)
self.assertAlmostEqual(result[0], 5.0)
self.assertAlmostEqual(result[1], 5.0)
def test_parallel_lines_no_intersect(self):
"""Parallel lines don't intersect."""
result = self.vision.line_segment_intersection(
(0, 0), (10, 0), # Horizontal line
(0, 5), (10, 5) # Parallel horizontal
)
self.assertIsNone(result)
def test_non_overlapping_segments(self):
"""Non-overlapping segments don't intersect."""
result = self.vision.line_segment_intersection(
(0, 0), (5, 0), # Short segment
(10, -5), (10, 5) # Far away vertical
)
self.assertIsNone(result)
class TestVisionPolygon(unittest.TestCase):
"""Tests for vision polygon casting."""
def test_no_walls_circular(self):
"""Without walls, vision polygon is circular."""
vision = RaycastVision(walls=[], base_vision_radius=100)
observer = Position(x=500, y=500)
points = vision.cast_rays_for_polygon(observer, num_rays=36)
# All points should be at radius distance
for px, py in points:
dist = ((px - observer.x)**2 + (py - observer.y)**2)**0.5
self.assertAlmostEqual(dist, 100.0, places=1)
def test_wall_creates_shadow(self):
"""Wall creates a shadow in vision polygon."""
# Wall directly in front
wall = Wall(p1=(550, 400), p2=(550, 600))
vision = RaycastVision(walls=[wall], base_vision_radius=200)
observer = Position(x=400, y=500)
points = vision.cast_rays_for_polygon(observer, num_rays=360)
# Some points should be closer than radius due to wall
min_dist = min(((px - observer.x)**2 + (py - observer.y)**2)**0.5 for px, py in points)
self.assertLess(min_dist, 200)
class TestImpostorVision(unittest.TestCase):
"""Tests for role-based vision differences."""
def test_impostor_has_larger_vision(self):
"""Impostors can see further than crewmates."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
impostor_vision=1.5
)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Check vision radii
crew_radius = vision.get_vision_radius_for_player(crewmate)
imp_radius = vision.get_vision_radius_for_player(impostor)
self.assertAlmostEqual(crew_radius, 300.0)
self.assertAlmostEqual(imp_radius, 450.0) # 300 * 1.5
def test_lights_sabotage_affects_crewmates(self):
"""Lights sabotage reduces crewmate vision."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
lights_multiplier=0.25
)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
# Before lights sabotage
normal_radius = vision.get_vision_radius_for_player(crewmate)
self.assertAlmostEqual(normal_radius, 300.0)
# After lights sabotage
vision.set_lights_sabotaged(True)
sabotaged_radius = vision.get_vision_radius_for_player(crewmate)
self.assertAlmostEqual(sabotaged_radius, 75.0) # 300 * 0.25
def test_lights_sabotage_does_not_affect_impostors(self):
"""Impostors are immune to lights sabotage."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
impostor_vision=1.5,
lights_multiplier=0.25
)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Before lights sabotage
normal_radius = vision.get_vision_radius_for_player(impostor)
# After lights sabotage
vision.set_lights_sabotaged(True)
sabotaged_radius = vision.get_vision_radius_for_player(impostor)
# Impostor vision should be unchanged
self.assertAlmostEqual(normal_radius, sabotaged_radius)
self.assertAlmostEqual(sabotaged_radius, 450.0)
def test_impostor_sees_further_during_lights_sabotage(self):
"""During lights sabotage, impostors can see targets crewmates cannot."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
impostor_vision=1.5,
lights_multiplier=0.25
)
vision.set_lights_sabotaged(True)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Target at 100 pixels away
target = Player(id="target", name="Target", color="green",
position=Position(x=100, y=0), role=Role.CREWMATE)
all_players = [crewmate, impostor, target]
# Crewmate sees 75px during sabotage, target is at 100px
crew_visible = vision.get_visible_players(crewmate, all_players)
self.assertEqual(len(crew_visible), 0) # Can't see target
# Impostor sees 450px, target is at 100px
imp_visible = vision.get_visible_players(impostor, all_players)
self.assertEqual(len(imp_visible), 2) # Can see both
if __name__ == "__main__":
unittest.main()