329 lines
12 KiB
Python
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()
|
|
|