Save work: Update game engine, maps, and add vision/pathing utilities. (Tests failing: vision raycast)

This commit is contained in:
Antigravity
2026-02-01 00:48:32 -05:00
parent 071906df59
commit be371e887a
18 changed files with 3270 additions and 303 deletions
+40 -22
View File
@@ -1,5 +1,6 @@
"""
Tests for the game engine.
Updated for pixel-based Position system.
"""
import unittest
@@ -14,40 +15,61 @@ from src.map.graph import GameMap, Room, Edge, Task, Vent
def create_simple_map():
"""Create a simple test map."""
"""Create a simple test map with pixel coordinates."""
game_map = GameMap()
game_map.spawn_points["cafeteria"] = Position(x=500, y=200)
# Cafeteria with task
game_map.add_room(Room(
id="cafeteria",
name="Cafeteria",
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0)]
center=Position(x=500, y=200),
bounds=(Position(x=400, y=100), Position(x=600, y=300)),
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0, position=Position(x=450, y=150))]
))
# Electrical with vent
elec_vent = Vent(id="vent_elec", connects_to=["vent_security"])
elec_vent = Vent(id="vent_elec", connects_to=["vent_security"], position=Position(x=200, y=450))
game_map.add_room(Room(
id="electrical",
name="Electrical",
center=Position(x=200, y=500),
bounds=(Position(x=100, y=400), Position(x=300, y=600)),
vent=elec_vent,
tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0)]
tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0, position=Position(x=250, y=550))]
))
# Security with vent
sec_vent = Vent(id="vent_security", connects_to=["vent_elec"])
sec_vent = Vent(id="vent_security", connects_to=["vent_elec"], position=Position(x=400, y=450))
game_map.add_room(Room(
id="security",
name="Security",
center=Position(x=400, y=500),
bounds=(Position(x=300, y=400), Position(x=500, y=600)),
vent=sec_vent
))
# Admin
game_map.add_room(Room(id="admin", name="Admin"))
game_map.add_room(Room(
id="admin",
name="Admin",
center=Position(x=700, y=200),
bounds=(Position(x=600, y=100), Position(x=800, y=300))
))
# Connect rooms
game_map.add_edge(Edge(id="cafe_elec", room_a="cafeteria", room_b="electrical", distance=5.0))
game_map.add_edge(Edge(id="cafe_admin", room_a="cafeteria", room_b="admin", distance=3.0))
game_map.add_edge(Edge(id="elec_sec", room_a="electrical", room_b="security", distance=4.0))
# Connect rooms with waypoints
game_map.add_edge(Edge(
id="cafe_elec", room_a="cafeteria", room_b="electrical",
waypoints=[Position(x=500, y=300), Position(x=300, y=400), Position(x=200, y=500)]
))
game_map.add_edge(Edge(
id="cafe_admin", room_a="cafeteria", room_b="admin",
waypoints=[Position(x=600, y=200), Position(x=700, y=200)]
))
game_map.add_edge(Edge(
id="elec_sec", room_a="electrical", room_b="security",
waypoints=[Position(x=300, y=500), Position(x=400, y=500)]
))
return game_map
@@ -96,10 +118,6 @@ class TestGameEngineSetup(unittest.TestCase):
self.assertIn("p1", self.engine.impostor_ids)
self.assertEqual(player.kill_cooldown, self.config.kill_cooldown)
def test_custom_player_speed(self):
player = self.engine.add_player("p1", "Red", "red", speed=3.0)
self.assertEqual(player.speed, 3.0)
def test_impostor_context(self):
self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR)
self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR)
@@ -176,7 +194,7 @@ class TestMovement(unittest.TestCase):
def test_move_no_path(self):
# Add isolated room
self.game_map.add_room(Room(id="isolated", name="Isolated"))
self.game_map.add_room(Room(id="isolated", name="Isolated", center=Position(x=1000, y=1000)))
self.engine.queue_action("p1", "MOVE", {"destination": "isolated"})
results = self.engine.resolve_actions()
@@ -222,7 +240,7 @@ class TestKill(unittest.TestCase):
def test_cannot_kill_different_room(self):
crew = self.engine.simulator.get_player("crew")
crew.position = Position(room_id="electrical")
crew.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
results = self.engine.resolve_actions()
@@ -262,7 +280,7 @@ class TestVenting(unittest.TestCase):
# Place impostor in electrical (has vent)
imp = self.engine.simulator.get_player("imp")
imp.position = Position(room_id="electrical")
imp.position = Position(x=200, y=500, room_id="electrical")
def test_impostor_can_vent(self):
self.engine.queue_action("imp", "VENT", {"destination": "security"})
@@ -272,7 +290,7 @@ class TestVenting(unittest.TestCase):
def test_crewmate_cannot_vent(self):
crew = self.engine.simulator.get_player("crew")
crew.position = Position(room_id="electrical")
crew.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("crew", "VENT", {"destination": "security"})
results = self.engine.resolve_actions()
@@ -282,7 +300,7 @@ class TestVenting(unittest.TestCase):
def test_cannot_vent_unconnected(self):
# Cafeteria has no vent
imp = self.engine.simulator.get_player("imp")
imp.position = Position(room_id="cafeteria")
imp.position = Position(x=500, y=200, room_id="cafeteria")
self.engine.queue_action("imp", "VENT", {"destination": "security"})
results = self.engine.resolve_actions()
@@ -336,7 +354,7 @@ class TestReporting(unittest.TestCase):
id="body1",
player_id="dead",
player_name="Blue",
position=Position(room_id="cafeteria"),
position=Position(x=500, y=200, room_id="cafeteria"),
time_of_death=0.0
)
self.engine.simulator.bodies.append(body)
@@ -349,7 +367,7 @@ class TestReporting(unittest.TestCase):
def test_cannot_report_body_in_different_room(self):
player = self.engine.simulator.get_player("p1")
player.position = Position(room_id="electrical")
player.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
results = self.engine.resolve_actions()
@@ -383,7 +401,7 @@ class TestEmergency(unittest.TestCase):
def test_cannot_call_emergency_outside_cafeteria(self):
player = self.engine.simulator.get_player("p1")
player.position = Position(room_id="electrical")
player.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("p1", "EMERGENCY", {})
results = self.engine.resolve_actions()
+71 -34
View File
@@ -6,11 +6,11 @@ import unittest
import sys
import os
import tempfile
import json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.map.graph import GameMap, Room, Edge, Task, Vent
from src.engine.types import Position
class TestRoom(unittest.TestCase):
@@ -34,18 +34,40 @@ class TestRoom(unittest.TestCase):
room = Room(id="test", name="Test Room", vent=vent)
self.assertIsNotNone(room.vent)
self.assertEqual(len(room.vent.connects_to), 2)
def test_room_with_center(self):
room = Room(id="test", name="Test Room", center=Position(x=100, y=200))
self.assertEqual(room.center.x, 100)
self.assertEqual(room.center.y, 200)
def test_room_contains_point(self):
room = Room(
id="test", name="Test Room",
center=Position(x=100, y=100),
bounds=(Position(x=0, y=0), Position(x=200, y=200))
)
self.assertTrue(room.contains_point(Position(x=100, y=100)))
self.assertTrue(room.contains_point(Position(x=50, y=50)))
self.assertFalse(room.contains_point(Position(x=300, y=100)))
class TestEdge(unittest.TestCase):
"""Tests for Edge dataclass."""
def test_edge_creation(self):
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0)
edge = Edge(id="e1", room_a="a", room_b="b")
self.assertEqual(edge.id, "e1")
self.assertEqual(edge.distance, 5.0)
self.assertEqual(edge.room_a, "a")
def test_edge_with_waypoints(self):
edge = Edge(
id="e1", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=100, y=0)]
)
self.assertAlmostEqual(edge.distance, 100.0)
def test_edge_other_room(self):
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0)
edge = Edge(id="e1", room_a="a", room_b="b")
self.assertEqual(edge.other_room("a"), "b")
self.assertEqual(edge.other_room("b"), "a")
@@ -60,14 +82,24 @@ class TestGameMap(unittest.TestCase):
# Create rooms: A -- B -- C
# |
# D
self.game_map.add_room(Room(id="a", name="Room A"))
self.game_map.add_room(Room(id="b", name="Room B"))
self.game_map.add_room(Room(id="c", name="Room C"))
self.game_map.add_room(Room(id="d", name="Room D"))
self.game_map.add_room(Room(id="a", name="Room A", center=Position(x=0, y=0)))
self.game_map.add_room(Room(id="b", name="Room B", center=Position(x=100, y=0)))
self.game_map.add_room(Room(id="c", name="Room C", center=Position(x=200, y=0)))
self.game_map.add_room(Room(id="d", name="Room D", center=Position(x=100, y=100)))
self.game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=3.0))
self.game_map.add_edge(Edge(id="bc", room_a="b", room_b="c", distance=4.0))
self.game_map.add_edge(Edge(id="bd", room_a="b", room_b="d", distance=2.0))
# Create edges with waypoints
self.game_map.add_edge(Edge(
id="ab", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=50, y=0), Position(x=100, y=0)]
))
self.game_map.add_edge(Edge(
id="bc", room_a="b", room_b="c",
waypoints=[Position(x=100, y=0), Position(x=200, y=0)]
))
self.game_map.add_edge(Edge(
id="bd", room_a="b", room_b="d",
waypoints=[Position(x=100, y=0), Position(x=100, y=100)]
))
def test_add_room(self):
self.assertEqual(len(self.game_map.rooms), 4)
@@ -87,7 +119,7 @@ class TestGameMap(unittest.TestCase):
def test_get_edge(self):
edge = self.game_map.get_edge("ab")
self.assertIsNotNone(edge)
self.assertEqual(edge.distance, 3.0)
self.assertAlmostEqual(edge.distance, 100.0)
def test_get_neighbors(self):
neighbors = self.game_map.get_neighbors("b")
@@ -126,20 +158,6 @@ class TestGameMap(unittest.TestCase):
self.game_map.add_room(Room(id="isolated", name="Isolated"))
path = self.game_map.find_path("a", "isolated")
self.assertIsNone(path)
def test_path_distance(self):
path = self.game_map.find_path("a", "c")
distance = self.game_map.path_distance(path)
self.assertEqual(distance, 7.0) # 3 + 4
def test_shortest_path(self):
# Add direct edge from a to d (should be longer)
self.game_map.add_edge(Edge(id="ad", room_a="a", room_b="d", distance=10.0))
# Shortest path should still go through b
path = self.game_map.find_path("a", "d")
distance = self.game_map.path_distance(path)
self.assertEqual(distance, 5.0) # 3 + 2 via b
class TestMapSerialization(unittest.TestCase):
@@ -147,9 +165,12 @@ class TestMapSerialization(unittest.TestCase):
def test_to_dict(self):
game_map = GameMap()
game_map.add_room(Room(id="a", name="A"))
game_map.add_room(Room(id="b", name="B"))
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
game_map.add_room(Room(id="a", name="A", center=Position(x=0, y=0)))
game_map.add_room(Room(id="b", name="B", center=Position(x=100, y=0)))
game_map.add_edge(Edge(
id="ab", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=100, y=0)]
))
data = game_map.to_dict()
self.assertEqual(len(data["rooms"]), 2)
@@ -157,11 +178,19 @@ class TestMapSerialization(unittest.TestCase):
def test_save_and_load(self):
game_map = GameMap()
task = Task(id="t1", name="Task", duration=3.0)
vent = Vent(id="v1", connects_to=["v2"])
game_map.add_room(Room(id="a", name="A", tasks=[task], vent=vent))
game_map.add_room(Room(id="b", name="B"))
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
task = Task(id="t1", name="Task", duration=3.0, position=Position(x=50, y=50))
vent = Vent(id="v1", connects_to=["v2"], position=Position(x=60, y=60))
game_map.add_room(Room(
id="a", name="A",
center=Position(x=50, y=50),
bounds=(Position(x=0, y=0), Position(x=100, y=100)),
tasks=[task], vent=vent
))
game_map.add_room(Room(id="b", name="B", center=Position(x=200, y=50)))
game_map.add_edge(Edge(
id="ab", room_a="a", room_b="b",
waypoints=[Position(x=100, y=50), Position(x=200, y=50)]
))
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
game_map.save(f.name)
@@ -213,6 +242,14 @@ class TestSkeldMap(unittest.TestCase):
medbay = self.skeld.get_room("medbay")
self.assertIn("vent_security", medbay.vent.connects_to)
self.assertIn("vent_elec", medbay.vent.connects_to)
def test_skeld_has_walls(self):
"""Skeld should have wall geometry for raycasting."""
self.assertGreater(len(self.skeld.walls), 0)
def test_skeld_has_spawn_points(self):
"""Skeld should have spawn points."""
self.assertIn("cafeteria", self.skeld.spawn_points)
if __name__ == "__main__":
+248
View File
@@ -0,0 +1,248 @@
"""
Tests for path utilities — walk interpolation.
"""
import unittest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.engine.types import Position
from src.engine.path_utils import WalkPath, WalkState, WalkManager, PathSegment
class TestPathSegment(unittest.TestCase):
"""Tests for PathSegment."""
def test_interpolate_start(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(0.0)
self.assertEqual(pos.x, 0)
self.assertEqual(pos.y, 0)
def test_interpolate_end(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(1.0)
self.assertEqual(pos.x, 100)
self.assertEqual(pos.y, 0)
def test_interpolate_middle(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(0.5)
self.assertAlmostEqual(pos.x, 50)
self.assertAlmostEqual(pos.y, 0)
class TestWalkPath(unittest.TestCase):
"""Tests for WalkPath."""
def test_straight_line_distance(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertAlmostEqual(path.total_distance, 100.0)
def test_multi_segment_distance(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0),
Position(x=100, y=100)
])
self.assertAlmostEqual(path.total_distance, 200.0)
def test_position_at_distance_start(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(0)
self.assertEqual(pos.x, 0)
def test_position_at_distance_end(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(100)
self.assertEqual(pos.x, 100)
def test_position_at_distance_middle(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(50)
self.assertAlmostEqual(pos.x, 50)
def test_position_at_distance_multi_segment(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0),
Position(x=100, y=100)
])
# At 150 pixels: first 100 to (100,0), then 50 down
pos = path.position_at_distance(150)
self.assertAlmostEqual(pos.x, 100)
self.assertAlmostEqual(pos.y, 50)
def test_position_at_time(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
# Speed 50 px/sec, after 1 sec = 50 px traveled
pos = path.position_at_time(elapsed=1.0, speed=50.0)
self.assertAlmostEqual(pos.x, 50)
def test_time_to_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
# 100 pixels at 50 px/sec = 2 seconds
self.assertAlmostEqual(path.time_to_complete(50.0), 2.0)
def test_is_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertFalse(path.is_complete(elapsed=1.0, speed=50.0))
self.assertTrue(path.is_complete(elapsed=3.0, speed=50.0))
def test_progress(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertAlmostEqual(path.progress(elapsed=1.0, speed=50.0), 0.5)
self.assertAlmostEqual(path.progress(elapsed=2.0, speed=50.0), 1.0)
def test_direct_path(self):
path = WalkPath.direct(Position(x=0, y=0), Position(x=100, y=100))
self.assertAlmostEqual(path.total_distance, 141.42, places=1)
class TestWalkState(unittest.TestCase):
"""Tests for WalkState."""
def test_current_position(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
# At time 11.0 (1 sec elapsed), should be at x=50
pos = state.current_position(11.0)
self.assertAlmostEqual(pos.x, 50)
def test_is_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
self.assertFalse(state.is_complete(11.0))
self.assertTrue(state.is_complete(13.0))
def test_arrival_time(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
self.assertAlmostEqual(state.arrival_time(), 12.0)
class TestWalkManager(unittest.TestCase):
"""Tests for WalkManager."""
def setUp(self):
self.manager = WalkManager()
self.path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
def test_start_walk(self):
state = self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.assertEqual(state.player_id, "p1")
self.assertIsNotNone(self.manager.get_walk_state("p1"))
def test_get_position(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
pos = self.manager.get_position("p1", 1.0)
self.assertIsNotNone(pos)
self.assertAlmostEqual(pos.x, 50)
def test_get_position_no_walk(self):
pos = self.manager.get_position("nonexistent", 1.0)
self.assertIsNone(pos)
def test_is_walking(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.assertTrue(self.manager.is_walking("p1", 1.0))
self.assertFalse(self.manager.is_walking("p1", 3.0))
def test_cancel_walk(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.cancel_walk("p1")
self.assertIsNone(self.manager.get_walk_state("p1"))
def test_cleanup_completed(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.start_walk("p2", self.path, 0.0, 100.0)
# p2 finishes at t=1, p1 at t=2
completed = self.manager.cleanup_completed(1.5)
self.assertEqual(len(completed), 1)
self.assertEqual(completed[0].player_id, "p2")
self.assertIsNone(self.manager.get_walk_state("p2"))
self.assertIsNotNone(self.manager.get_walk_state("p1"))
def test_get_all_positions(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.start_walk("p2", self.path, 0.0, 100.0)
positions = self.manager.get_all_positions(1.0)
self.assertEqual(len(positions), 2)
self.assertAlmostEqual(positions["p1"].x, 50)
self.assertAlmostEqual(positions["p2"].x, 100)
if __name__ == "__main__":
unittest.main()
+12 -12
View File
@@ -39,21 +39,21 @@ class TestPlayer(unittest.TestCase):
player = Player(
id="p1", name="Red", color="red",
role=Role.CREWMATE,
position=Position(room_id="cafeteria")
position=Position(x=1000, y=400, room_id="cafeteria")
)
self.assertEqual(player.id, "p1")
self.assertEqual(player.role, Role.CREWMATE)
self.assertTrue(player.is_alive)
def test_position_in_room(self):
pos = Position(room_id="cafeteria")
self.assertTrue(pos.is_in_room())
self.assertFalse(pos.is_on_edge())
def test_position_distance(self):
pos1 = Position(x=0, y=0)
pos2 = Position(x=3, y=4)
self.assertAlmostEqual(pos1.distance_to(pos2), 5.0)
def test_position_on_edge(self):
pos = Position(edge_id="ab", progress=0.5)
self.assertFalse(pos.is_in_room())
self.assertTrue(pos.is_on_edge())
def test_position_with_room(self):
pos = Position(x=100, y=200, room_id="cafeteria")
self.assertEqual(pos.room_id, "cafeteria")
self.assertEqual(pos.x, 100)
class TestSimulator(unittest.TestCase):
@@ -196,9 +196,9 @@ class TestSimulator(unittest.TestCase):
self.assertEqual(len(living), 2)
def test_players_at(self):
p1 = Player(id="p1", name="Red", color="red", position=Position(room_id="cafeteria"))
p2 = Player(id="p2", name="Blue", color="blue", position=Position(room_id="cafeteria"))
p3 = Player(id="p3", name="Green", color="green", position=Position(room_id="admin"))
p1 = Player(id="p1", name="Red", color="red", position=Position(x=1000, y=400, room_id="cafeteria"))
p2 = Player(id="p2", name="Blue", color="blue", position=Position(x=1000, y=400, room_id="cafeteria"))
p3 = Player(id="p3", name="Green", color="green", position=Position(x=1200, y=700, room_id="admin"))
self.sim.add_player(p1)
self.sim.add_player(p2)
+328
View File
@@ -0,0 +1,328 @@
"""
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()