From be371e887a91e2bd7669ef861bf8eb66a9bb93e8 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 1 Feb 2026 00:48:32 -0500 Subject: [PATCH] Save work: Update game engine, maps, and add vision/pathing utilities. (Tests failing: vision raycast) --- config/game_settings.json | 25 +- config/game_settings.yaml | 52 +- data/maps/skeld.json | 1117 +++++++++++++++++++++++++++++--- docs/api.md | 50 +- docs/design_rendering.md | 164 +++++ scripts/config_manager.py | 442 +++++++++++++ src/agents/prompt_assembler.py | 6 +- src/engine/game.py | 114 ++-- src/engine/path_utils.py | 248 +++++++ src/engine/simulator.py | 2 +- src/engine/types.py | 75 ++- src/engine/vision_raycast.py | 313 +++++++++ src/map/graph.py | 198 +++++- tests/test_game.py | 62 +- tests/test_map.py | 105 ++- tests/test_path_utils.py | 248 +++++++ tests/test_simulator.py | 24 +- tests/test_vision_raycast.py | 328 ++++++++++ 18 files changed, 3270 insertions(+), 303 deletions(-) create mode 100644 docs/design_rendering.md create mode 100755 scripts/config_manager.py create mode 100644 src/engine/path_utils.py create mode 100644 src/engine/vision_raycast.py create mode 100644 tests/test_path_utils.py create mode 100644 tests/test_vision_raycast.py diff --git a/config/game_settings.json b/config/game_settings.json index 9d1dd8c..c5174dd 100644 --- a/config/game_settings.json +++ b/config/game_settings.json @@ -1,14 +1,27 @@ { + "_comment": "Complete Among Us lobby settings", + "map_name": "skeld", "num_impostors": 2, + "player_speed": 100.0, + "crewmate_vision": 1.0, + "impostor_vision": 1.5, "kill_cooldown": 25.0, - "vision_range": 10.0, - "impostor_vision_multiplier": 1.5, - "light_sabotage_vision_multiplier": 0.25, + "kill_distance": "medium", "emergencies_per_player": 1, + "emergency_cooldown": 15.0, + "discussion_time": 30.0, + "voting_time": 120.0, "confirm_ejects": true, - "player_speed": 2.0, - "task_duration": 3.0, + "anonymous_votes": false, + "visual_tasks": true, + "taskbar_updates": "always", + "common_tasks": 2, + "long_tasks": 1, + "short_tasks": 2, "sabotage_cooldown": 30.0, + "reactor_timer": 45.0, "o2_timer": 45.0, - "reactor_timer": 45.0 + "lights_vision_multiplier": 0.25, + "max_discussion_rounds": 20, + "convergence_threshold": 2 } \ No newline at end of file diff --git a/config/game_settings.yaml b/config/game_settings.yaml index faa5d46..79ca573 100644 --- a/config/game_settings.yaml +++ b/config/game_settings.yaml @@ -1,40 +1,40 @@ -# Game Settings Configuration -# Edit this file to customize game rules +# The Glass Box League — Complete Among Us Settings +# All values match the in-game lobby settings panel game: - map: "skeld" - min_players: 4 - max_players: 10 + map_name: skeld num_impostors: 2 player: - speed: 1.5 # meters per second - vision_range: 10.0 # meters - + player_speed: 100.0 # pixels/second (1.0x = 100px/s) + crewmate_vision: 1.0 # multiplier (1.0x = 300px radius) + impostor_vision: 1.5 # multiplier (not affected by lights) + impostor: - kill_cooldown: 25.0 # seconds - kill_range: 2.0 # meters - -crewmate: - tasks_short: 2 - tasks_long: 1 - tasks_common: 2 + kill_cooldown: 25.0 # seconds + kill_distance: medium # short (50px), medium (100px), long (150px) meeting: - emergency_cooldown: 15.0 # seconds emergencies_per_player: 1 - discussion_time: 30.0 # seconds (for human mode) - voting_time: 60.0 # seconds (for human mode) - confirm_ejects: true + emergency_cooldown: 15.0 # seconds + discussion_time: 30.0 # seconds + voting_time: 120.0 # seconds + confirm_ejects: true # "Red was The Impostor" vs "Red was ejected" + anonymous_votes: false # hide who voted for whom + +tasks: + visual_tasks: true # can see others doing visual tasks + taskbar_updates: always # always, meetings, never + common_tasks: 2 + long_tasks: 1 + short_tasks: 2 sabotage: - o2_timer: 30.0 # seconds until death - reactor_timer: 30.0 # seconds until meltdown - lights_vision_multiplier: 0.25 - comms_disables_tasks: true + sabotage_cooldown: 30.0 # seconds between sabotages + reactor_timer: 45.0 # seconds to fix reactor + o2_timer: 45.0 # seconds to fix O2 + lights_vision_multiplier: 0.25 # crewmate vision during lights sabotage -# LLM-specific settings llm: max_discussion_rounds: 20 - min_convergence_rounds: 2 - convergence_threshold: 2 # desire_to_speak <= this = silence + convergence_threshold: 2 diff --git a/data/maps/skeld.json b/data/maps/skeld.json index e2f59f7..414ccc8 100644 --- a/data/maps/skeld.json +++ b/data/maps/skeld.json @@ -1,63 +1,142 @@ { + "name": "The Skeld", + "width": 2000, + "height": 1500, + "vision_radius": 300, + "vision_radius_sabotaged": 150, + "spawn_points": { + "cafeteria": [ + 1000, + 400 + ] + }, "rooms": [ { "id": "cafeteria", "name": "Cafeteria", - "x": 0, - "y": 0, + "center": [ + 1000, + 400 + ], + "bounds": [ + [ + 900, + 300 + ], + [ + 1100, + 500 + ] + ], "tasks": [ { "id": "empty_garbage_cafe", "name": "Empty Garbage", - "duration": 3.0 + "duration": 3.0, + "position": [ + 950, + 450 + ] }, { "id": "download_cafe", "name": "Download Data", - "duration": 8.0 + "duration": 8.0, + "position": [ + 1050, + 350 + ] }, { "id": "fix_wiring_cafe", "name": "Fix Wiring", - "duration": 3.0 + "duration": 3.0, + "position": [ + 920, + 380 + ] } ], - "vent": null + "vent": null, + "emergency_button": [ + 1000, + 400 + ] }, { "id": "weapons", "name": "Weapons", - "x": 5, - "y": -3, + "center": [ + 1400, + 200 + ], + "bounds": [ + [ + 1300, + 100 + ], + [ + 1500, + 300 + ] + ], "tasks": [ { "id": "clear_asteroids", "name": "Clear Asteroids", - "duration": 10.0 + "duration": 10.0, + "position": [ + 1450, + 150 + ] } ], "vent": { "id": "vent_weapons", "connects_to": [ "vent_nav" + ], + "position": [ + 1350, + 250 ] } }, { "id": "navigation", "name": "Navigation", - "x": 10, - "y": -3, + "center": [ + 1800, + 400 + ], + "bounds": [ + [ + 1700, + 300 + ], + [ + 1900, + 500 + ] + ], "tasks": [ { "id": "chart_course", "name": "Chart Course", - "duration": 3.0 + "duration": 3.0, + "position": [ + 1850, + 350 + ] }, { "id": "stabilize_steering", "name": "Stabilize Steering", - "duration": 5.0 + "duration": 5.0, + "position": [ + 1750, + 450 + ] } ], "vent": { @@ -65,24 +144,48 @@ "connects_to": [ "vent_weapons", "vent_shields" + ], + "position": [ + 1800, + 480 ] } }, { "id": "o2", "name": "O2", - "x": 7, - "y": 0, + "center": [ + 1400, + 500 + ], + "bounds": [ + [ + 1300, + 400 + ], + [ + 1500, + 600 + ] + ], "tasks": [ { "id": "clean_filter", "name": "Clean O2 Filter", - "duration": 4.0 + "duration": 4.0, + "position": [ + 1450, + 450 + ] }, { "id": "empty_garbage_o2", "name": "Empty Garbage", - "duration": 3.0 + "duration": 3.0, + "position": [ + 1350, + 550 + ] } ], "vent": null @@ -90,42 +193,86 @@ { "id": "admin", "name": "Admin", - "x": 3, - "y": 3, + "center": [ + 1200, + 700 + ], + "bounds": [ + [ + 1100, + 600 + ], + [ + 1300, + 800 + ] + ], "tasks": [ { "id": "swipe_card", "name": "Swipe Card", - "duration": 5.0 + "duration": 5.0, + "position": [ + 1250, + 650 + ] }, { "id": "upload_data", "name": "Upload Data", - "duration": 8.0 + "duration": 8.0, + "position": [ + 1150, + 750 + ] } ], "vent": { "id": "vent_admin", "connects_to": [ "vent_cafe_hall" + ], + "position": [ + 1200, + 780 ] } }, { "id": "storage", "name": "Storage", - "x": 0, - "y": 6, + "center": [ + 800, + 900 + ], + "bounds": [ + [ + 650, + 750 + ], + [ + 950, + 1050 + ] + ], "tasks": [ { "id": "fuel_engines", "name": "Fuel Engines", - "duration": 4.0 + "duration": 4.0, + "position": [ + 750, + 850 + ] }, { "id": "empty_garbage_storage", "name": "Empty Garbage", - "duration": 3.0 + "duration": 3.0, + "position": [ + 850, + 950 + ] } ], "vent": null @@ -133,13 +280,29 @@ { "id": "communications", "name": "Communications", - "x": 5, - "y": 6, + "center": [ + 1200, + 1100 + ], + "bounds": [ + [ + 1100, + 1000 + ], + [ + 1300, + 1200 + ] + ], "tasks": [ { "id": "download_comms", "name": "Download Data", - "duration": 8.0 + "duration": 8.0, + "position": [ + 1200, + 1100 + ] } ], "vent": null @@ -147,42 +310,86 @@ { "id": "shields", "name": "Shields", - "x": 8, - "y": 5, + "center": [ + 1500, + 900 + ], + "bounds": [ + [ + 1400, + 800 + ], + [ + 1600, + 1000 + ] + ], "tasks": [ { "id": "prime_shields", "name": "Prime Shields", - "duration": 5.0 + "duration": 5.0, + "position": [ + 1550, + 850 + ] } ], "vent": { "id": "vent_shields", "connects_to": [ "vent_nav" + ], + "position": [ + 1450, + 950 ] } }, { "id": "electrical", "name": "Electrical", - "x": -3, - "y": 6, + "center": [ + 500, + 900 + ], + "bounds": [ + [ + 400, + 800 + ], + [ + 600, + 1000 + ] + ], "tasks": [ { "id": "calibrate_distributor", "name": "Calibrate Distributor", - "duration": 6.0 + "duration": 6.0, + "position": [ + 550, + 850 + ] }, { "id": "download_elec", "name": "Download Data", - "duration": 8.0 + "duration": 8.0, + "position": [ + 450, + 950 + ] }, { "id": "fix_wiring_elec", "name": "Fix Wiring", - "duration": 3.0 + "duration": 3.0, + "position": [ + 500, + 880 + ] } ], "vent": { @@ -190,62 +397,126 @@ "connects_to": [ "vent_security", "vent_medbay" + ], + "position": [ + 500, + 820 ] } }, { "id": "lower_engine", "name": "Lower Engine", - "x": -6, - "y": 4, + "center": [ + 250, + 800 + ], + "bounds": [ + [ + 150, + 700 + ], + [ + 350, + 900 + ] + ], "tasks": [ { "id": "align_lower", "name": "Align Engine Output", - "duration": 4.0 + "duration": 4.0, + "position": [ + 300, + 750 + ] }, { "id": "fuel_lower", "name": "Fuel Engines", - "duration": 4.0 + "duration": 4.0, + "position": [ + 200, + 850 + ] } ], "vent": { "id": "vent_lower", "connects_to": [ "vent_reactor" + ], + "position": [ + 250, + 780 ] } }, { "id": "security", "name": "Security", - "x": -5, - "y": 1, + "center": [ + 300, + 500 + ], + "bounds": [ + [ + 200, + 400 + ], + [ + 400, + 600 + ] + ], "tasks": [], "vent": { "id": "vent_security", "connects_to": [ "vent_medbay", "vent_elec" + ], + "position": [ + 350, + 550 ] } }, { "id": "reactor", "name": "Reactor", - "x": -8, - "y": 0, + "center": [ + 150, + 500 + ], + "bounds": [ + [ + 50, + 400 + ], + [ + 250, + 600 + ] + ], "tasks": [ { "id": "start_reactor", "name": "Start Reactor", - "duration": 15.0 + "duration": 15.0, + "position": [ + 100, + 450 + ] }, { "id": "unlock_manifolds", "name": "Unlock Manifolds", - "duration": 5.0 + "duration": 5.0, + "position": [ + 200, + 550 + ] } ], "vent": { @@ -253,43 +524,87 @@ "connects_to": [ "vent_upper", "vent_lower" + ], + "position": [ + 150, + 580 ] } }, { "id": "upper_engine", "name": "Upper Engine", - "x": -6, - "y": -2, + "center": [ + 250, + 250 + ], + "bounds": [ + [ + 150, + 150 + ], + [ + 350, + 350 + ] + ], "tasks": [ { "id": "align_upper", "name": "Align Engine Output", - "duration": 4.0 + "duration": 4.0, + "position": [ + 300, + 200 + ] } ], "vent": { "id": "vent_upper", "connects_to": [ "vent_reactor" + ], + "position": [ + 250, + 330 ] } }, { "id": "medbay", "name": "MedBay", - "x": -3, - "y": -2, + "center": [ + 500, + 300 + ], + "bounds": [ + [ + 400, + 200 + ], + [ + 600, + 400 + ] + ], "tasks": [ { "id": "submit_scan", "name": "Submit Scan", - "duration": 10.0 + "duration": 10.0, + "position": [ + 550, + 250 + ] }, { "id": "inspect_sample", "name": "Inspect Sample", - "duration": 60.0 + "duration": 60.0, + "position": [ + 450, + 350 + ] } ], "vent": { @@ -297,6 +612,10 @@ "connects_to": [ "vent_security", "vent_elec" + ], + "position": [ + 500, + 380 ] } } @@ -306,176 +625,768 @@ "id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", - "distance": 5.0, - "waypoints": [] + "waypoints": [ + [ + 1100, + 350 + ], + [ + 1200, + 250 + ], + [ + 1300, + 200 + ] + ] }, { "id": "cafe_admin", "room_a": "cafeteria", "room_b": "admin", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1050, + 500 + ], + [ + 1100, + 600 + ] + ] }, { "id": "cafe_storage", "room_a": "cafeteria", "room_b": "storage", - "distance": 6.0, - "waypoints": [] + "waypoints": [ + [ + 950, + 500 + ], + [ + 900, + 650 + ], + [ + 850, + 750 + ] + ] }, { "id": "cafe_medbay", "room_a": "cafeteria", "room_b": "medbay", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 900, + 400 + ], + [ + 700, + 350 + ], + [ + 600, + 300 + ] + ] }, { "id": "cafe_upper", "room_a": "cafeteria", "room_b": "upper_engine", - "distance": 6.0, - "waypoints": [] + "waypoints": [ + [ + 900, + 350 + ], + [ + 600, + 300 + ], + [ + 400, + 280 + ], + [ + 350, + 250 + ] + ] }, { "id": "weapons_o2", "room_a": "weapons", "room_b": "o2", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1400, + 300 + ], + [ + 1400, + 400 + ] + ] }, { "id": "weapons_nav", "room_a": "weapons", "room_b": "navigation", - "distance": 5.0, - "waypoints": [] + "waypoints": [ + [ + 1500, + 200 + ], + [ + 1600, + 300 + ], + [ + 1700, + 400 + ] + ] }, { "id": "nav_o2", "room_a": "navigation", "room_b": "o2", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1700, + 450 + ], + [ + 1600, + 500 + ], + [ + 1500, + 500 + ] + ] }, { "id": "nav_shields", "room_a": "navigation", "room_b": "shields", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1750, + 500 + ], + [ + 1650, + 700 + ], + [ + 1550, + 850 + ] + ] }, { "id": "o2_shields", "room_a": "o2", "room_b": "shields", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1450, + 600 + ], + [ + 1500, + 750 + ], + [ + 1500, + 850 + ] + ] }, { "id": "o2_admin", "room_a": "o2", "room_b": "admin", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1350, + 550 + ], + [ + 1300, + 650 + ] + ] }, { "id": "admin_storage", "room_a": "admin", "room_b": "storage", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1100, + 750 + ], + [ + 1000, + 800 + ], + [ + 900, + 850 + ] + ] }, { "id": "shields_comms", "room_a": "shields", "room_b": "communications", - "distance": 3.0, - "waypoints": [] + "waypoints": [ + [ + 1450, + 1000 + ], + [ + 1350, + 1050 + ], + [ + 1300, + 1100 + ] + ] }, { "id": "shields_storage", "room_a": "shields", "room_b": "storage", - "distance": 5.0, - "waypoints": [] + "waypoints": [ + [ + 1400, + 900 + ], + [ + 1200, + 950 + ], + [ + 1000, + 950 + ], + [ + 900, + 900 + ] + ] }, { "id": "comms_storage", "room_a": "communications", "room_b": "storage", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 1100, + 1050 + ], + [ + 1000, + 1000 + ], + [ + 900, + 950 + ] + ] }, { "id": "storage_elec", "room_a": "storage", "room_b": "electrical", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 700, + 850 + ], + [ + 600, + 880 + ] + ] }, { "id": "storage_lower", "room_a": "storage", "room_b": "lower_engine", - "distance": 6.0, - "waypoints": [] + "waypoints": [ + [ + 700, + 800 + ], + [ + 500, + 800 + ], + [ + 350, + 800 + ] + ] }, { "id": "elec_lower", "room_a": "electrical", "room_b": "lower_engine", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 450, + 850 + ], + [ + 350, + 850 + ] + ] }, { "id": "lower_security", "room_a": "lower_engine", "room_b": "security", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 280, + 700 + ], + [ + 300, + 600 + ] + ] }, { "id": "lower_reactor", "room_a": "lower_engine", "room_b": "reactor", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 200, + 750 + ], + [ + 180, + 600 + ] + ] }, { "id": "security_reactor", "room_a": "security", "room_b": "reactor", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 250, + 500 + ] + ] }, { "id": "security_upper", "room_a": "security", "room_b": "upper_engine", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 300, + 400 + ], + [ + 280, + 350 + ] + ] }, { "id": "reactor_upper", "room_a": "reactor", "room_b": "upper_engine", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 180, + 400 + ], + [ + 200, + 350 + ] + ] }, { "id": "upper_medbay", "room_a": "upper_engine", "room_b": "medbay", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 350, + 280 + ], + [ + 420, + 300 + ] + ] }, { "id": "medbay_security", "room_a": "medbay", "room_b": "security", - "distance": 4.0, - "waypoints": [] + "waypoints": [ + [ + 450, + 400 + ], + [ + 400, + 480 + ], + [ + 350, + 500 + ] + ] + } + ], + "walls": [ + { + "p1": [ + 850, + 280 + ], + "p2": [ + 850, + 520 + ], + "room": "cafeteria_left" + }, + { + "p1": [ + 1150, + 280 + ], + "p2": [ + 1150, + 520 + ], + "room": "cafeteria_right" + }, + { + "p1": [ + 850, + 280 + ], + "p2": [ + 1150, + 280 + ], + "room": "cafeteria_top" + }, + { + "p1": [ + 850, + 520 + ], + "p2": [ + 1150, + 520 + ], + "room": "cafeteria_bottom" + }, + { + "p1": [ + 1280, + 80 + ], + "p2": [ + 1280, + 320 + ], + "room": "weapons_left" + }, + { + "p1": [ + 1520, + 80 + ], + "p2": [ + 1520, + 320 + ], + "room": "weapons_right" + }, + { + "p1": [ + 1680, + 280 + ], + "p2": [ + 1680, + 520 + ], + "room": "navigation_left" + }, + { + "p1": [ + 1920, + 280 + ], + "p2": [ + 1920, + 520 + ], + "room": "navigation_right" + }, + { + "p1": [ + 380, + 180 + ], + "p2": [ + 380, + 420 + ], + "room": "medbay_left" + }, + { + "p1": [ + 620, + 180 + ], + "p2": [ + 620, + 420 + ], + "room": "medbay_right" + }, + { + "p1": [ + 30, + 380 + ], + "p2": [ + 30, + 620 + ], + "room": "reactor_left" + }, + { + "p1": [ + 270, + 380 + ], + "p2": [ + 270, + 620 + ], + "room": "reactor_right" + }, + { + "p1": [ + 180, + 380 + ], + "p2": [ + 180, + 620 + ], + "room": "security_left" + }, + { + "p1": [ + 420, + 380 + ], + "p2": [ + 420, + 620 + ], + "room": "security_right" + }, + { + "p1": [ + 130, + 680 + ], + "p2": [ + 130, + 920 + ], + "room": "lower_left" + }, + { + "p1": [ + 370, + 680 + ], + "p2": [ + 370, + 920 + ], + "room": "lower_right" + }, + { + "p1": [ + 380, + 780 + ], + "p2": [ + 380, + 1020 + ], + "room": "electrical_left" + }, + { + "p1": [ + 620, + 780 + ], + "p2": [ + 620, + 1020 + ], + "room": "electrical_right" + }, + { + "p1": [ + 630, + 730 + ], + "p2": [ + 630, + 1070 + ], + "room": "storage_left" + }, + { + "p1": [ + 970, + 730 + ], + "p2": [ + 970, + 1070 + ], + "room": "storage_right" + }, + { + "p1": [ + 1080, + 580 + ], + "p2": [ + 1080, + 820 + ], + "room": "admin_left" + }, + { + "p1": [ + 1320, + 580 + ], + "p2": [ + 1320, + 820 + ], + "room": "admin_right" + }, + { + "p1": [ + 1280, + 380 + ], + "p2": [ + 1280, + 620 + ], + "room": "o2_left" + }, + { + "p1": [ + 1520, + 380 + ], + "p2": [ + 1520, + 620 + ], + "room": "o2_right" + }, + { + "p1": [ + 1380, + 780 + ], + "p2": [ + 1380, + 1020 + ], + "room": "shields_left" + }, + { + "p1": [ + 1620, + 780 + ], + "p2": [ + 1620, + 1020 + ], + "room": "shields_right" + }, + { + "p1": [ + 1080, + 980 + ], + "p2": [ + 1080, + 1220 + ], + "room": "comms_left" + }, + { + "p1": [ + 1320, + 980 + ], + "p2": [ + 1320, + 1220 + ], + "room": "comms_right" + }, + { + "p1": [ + 130, + 130 + ], + "p2": [ + 130, + 370 + ], + "room": "upper_left" + }, + { + "p1": [ + 370, + 130 + ], + "p2": [ + 370, + 370 + ], + "room": "upper_right" } ] } \ No newline at end of file diff --git a/docs/api.md b/docs/api.md index 535bfc0..b905e0a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -204,27 +204,53 @@ class LLMClient: ## Configuration -### `config/game_settings.yaml` -```yaml -num_impostors: 2 -kill_cooldown: 25.0 -vision_range: 10.0 -impostor_vision_multiplier: 1.5 -light_sabotage_vision_multiplier: 0.25 -emergencies_per_player: 1 -confirm_ejects: true +Use the CLI config manager for easy configuration: + +```bash +python scripts/config_manager.py settings list # List all settings +python scripts/config_manager.py settings get kill_cooldown +python scripts/config_manager.py settings set kill_cooldown 30 +python scripts/config_manager.py validate # Validate all configs ``` +### `config/game_settings.json` +```json +{ + "map_name": "skeld", + "num_impostors": 2, + "player_speed": 100.0, + "crewmate_vision": 1.0, + "impostor_vision": 1.5, + "kill_cooldown": 25.0, + "kill_distance": "medium", + "emergencies_per_player": 1, + "discussion_time": 30.0, + "voting_time": 120.0, + "confirm_ejects": true, + "visual_tasks": true, + "taskbar_updates": "always" +} +``` + +All measurements are in **pixels** (map is 2000x1500). Speed is pixels/second. + ### `data/maps/skeld.json` ```json { + "canvas": {"width": 2000, "height": 1500}, "rooms": [ - {"id": "cafeteria", "name": "Cafeteria", "tasks": [...], "vent": null}, + {"id": "cafeteria", "name": "Cafeteria", "center": [1000, 350], "bounds": [[850, 200], [1150, 500]]}, ... ], "edges": [ - {"id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", "distance": 5.0}, + {"id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", "waypoints": [[1150, 350], [1300, 350]]}, ... - ] + ], + "walls": [ + {"start": [850, 200], "end": [1150, 200]}, + ... + ], + "spawn_points": {"cafeteria": [1000, 400]} } ``` + diff --git a/docs/design_rendering.md b/docs/design_rendering.md new file mode 100644 index 0000000..f0a1506 --- /dev/null +++ b/docs/design_rendering.md @@ -0,0 +1,164 @@ +# Ray-Traced FOV & Video Rendering Pipeline + +## Current State +- Vision is **room-based** (graph traversal) +- Player sees everyone in same room +- No pixel-level visibility, no wall occlusion + +## Target State +- **Ray-traced FOV** matching real Among Us +- Walls block visibility +- Circular vision radius from player +- Light sabotage reduces radius +- Video output at **60fps** for YouTube + +--- + +## Part 1: Ray-Traced FOV + +### Map Data +Current `skeld.json` has rooms + edges (corridors). Need to add: + +```json +{ + "walls": [ + {"p1": [100, 50], "p2": [100, 200]}, + {"p1": [100, 200], "p2": [250, 200]} + ], + "spawn_positions": {"cafeteria": [300, 400]} +} +``` + +### Visibility Algorithm +1. **Cast rays** from player position in 360° (e.g., 360 rays) +2. **Intersect** each ray with wall segments +3. **Closest intersection** per ray = vision boundary +4. **Vision radius** clamps max distance +5. **Player visible** if: within radius AND not occluded by walls + +### Implementation +- `src/engine/vision_raycast.py` — new module +- `RaycastVisionSystem.get_visible_players(observer_pos, all_players, walls)` +- Returns: list of visible player IDs + positions + +--- + +## Part 2: Engine Changes + +### Current Position Model (`types.py`) +```python +@dataclass +class Position: + room_id: Optional[str] = None # Discrete room + edge_id: Optional[str] = None # Walking between rooms + progress: float = 0.0 # 0.0-1.0 on edge +``` + +### New Position Model +```python +@dataclass +class Position: + x: float = 0.0 # Pixel X + y: float = 0.0 # Pixel Y + room_id: Optional[str] = None # Derived from position + + def distance_to(self, other: "Position") -> float: + return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2) +``` + +### New Map Data (`skeld.json`) +```json +{ + "rooms": [...], + "edges": [...], + "walls": [ + {"p1": [100, 50], "p2": [100, 200]}, + ... + ], + "room_polygons": { + "cafeteria": [[x1,y1], [x2,y2], ...], + ... + }, + "spawn_points": { + "cafeteria": [300, 400], + ... + } +} +``` + +### New Module: `vision_raycast.py` +```python +class RaycastVision: + def __init__(self, walls: list[Wall], vision_radius: float): + ... + + def is_visible(self, from_pos: Position, to_pos: Position) -> bool: + """True if line-of-sight exists (no wall occlusion).""" + ... + + def get_visible_players(self, observer: Position, + all_players: list[Player]) -> list[Player]: + """Returns players within vision radius AND line-of-sight.""" + ... + + def get_vision_polygon(self, observer: Position) -> list[tuple]: + """For rendering: polygon representing visible area.""" + ... +``` + +### Wall Intersection Algorithm +```python +def ray_intersects_wall(ray_start, ray_dir, wall_p1, wall_p2) -> float | None: + """Returns distance to intersection, or None if no hit.""" + # Standard line-segment intersection math +``` + +--- + +## Part 3: Rendering Pipeline + +### Frame Generation (60fps) +``` +replay.json → Renderer → frames/0001.png, 0002.png, ... +``` + +### Per Frame: +1. Draw map background (Skeld PNG) +2. Apply FOV mask (ray-traced vignette) +3. Draw player sprites at interpolated positions +4. Draw bodies +5. Overlay effects (kill, vent, sabotage) + +### Video Assembly +```bash +ffmpeg -framerate 60 -i frames/%04d.png -c:v libx264 output.mp4 +``` + +--- + +## Part 4: Assets + +| Asset | Format | Source | +|-------|--------|--------| +| Skeld map | PNG | Fan art / game extract | +| Crewmate sprites | Spritesheet | Available online | +| Kill animations | Sprite sequence | Extract or recreate | +| Meeting UI | HTML/PNG | Recreate | + +--- + +## Implementation Order + +1. **Map upgrade** — Add walls + pixel coords +2. **Raycast vision** — `vision_raycast.py` +3. **Pixel positions** — Upgrade engine to (x,y) +4. **Path interpolation** — Smooth walking +5. **Frame renderer** — Pillow/Pygame +6. **Meeting renderer** — Overlay +7. **FFmpeg integration** — Stitch to video + +## Questions + +1. **POV style**: Single player POV, or omniscient? +2. **Internal thoughts**: Show as subtitles? +3. **TTS**: Voice for dialogue? diff --git a/scripts/config_manager.py b/scripts/config_manager.py new file mode 100755 index 0000000..f354ffb --- /dev/null +++ b/scripts/config_manager.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +""" +The Glass Box League — Configuration Manager CLI + +Unified CLI for managing all game configurations: +- Game settings (lobby options) +- Prompt templates +- Map data +- Model configurations +""" + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Optional + +# Add project root to path +PROJECT_ROOT = Path(__file__).parent.parent +sys.path.insert(0, str(PROJECT_ROOT)) + +try: + import yaml + HAS_YAML = True +except ImportError: + HAS_YAML = False + + +class ConfigManager: + """Unified configuration manager.""" + + def __init__(self, project_root: Path = None): + self.root = project_root or PROJECT_ROOT + self.config_dir = self.root / "config" + self.data_dir = self.root / "data" + self.prompts_dir = self.config_dir / "prompts" + + # ------------------------------------------------------------------------- + # Game Settings + # ------------------------------------------------------------------------- + + def get_settings_path(self, format: str = "json") -> Path: + """Get path to game settings file.""" + return self.config_dir / f"game_settings.{format}" + + def load_settings(self) -> dict: + """Load game settings (prefers YAML if available).""" + yaml_path = self.get_settings_path("yaml") + json_path = self.get_settings_path("json") + + if HAS_YAML and yaml_path.exists(): + with open(yaml_path) as f: + return yaml.safe_load(f) + elif json_path.exists(): + with open(json_path) as f: + return json.load(f) + return {} + + def save_settings(self, settings: dict, format: str = "json"): + """Save game settings.""" + path = self.get_settings_path(format) + + if format == "yaml" and HAS_YAML: + with open(path, "w") as f: + yaml.dump(settings, f, default_flow_style=False, sort_keys=False) + else: + with open(path, "w") as f: + json.dump(settings, f, indent=2) + + print(f"✓ Saved settings to {path}") + + def get_setting(self, key: str) -> Optional[any]: + """Get a single setting value (supports nested keys like 'game.num_impostors').""" + settings = self.load_settings() + + # Handle nested keys + parts = key.split(".") + value = settings + for part in parts: + if isinstance(value, dict) and part in value: + value = value[part] + else: + return None + return value + + def set_setting(self, key: str, value: str): + """Set a single setting value.""" + settings = self.load_settings() + + # Auto-detect type + parsed_value = self._parse_value(value) + + # Handle nested keys + parts = key.split(".") + current = settings + for part in parts[:-1]: + if part not in current: + current[part] = {} + current = current[part] + current[parts[-1]] = parsed_value + + # Save in same format as loaded + format = "yaml" if HAS_YAML and self.get_settings_path("yaml").exists() else "json" + self.save_settings(settings, format) + print(f"✓ Set {key} = {parsed_value}") + + def _parse_value(self, value: str): + """Parse string value to appropriate type.""" + # Boolean + if value.lower() in ("true", "yes", "on"): + return True + if value.lower() in ("false", "no", "off"): + return False + + # Number + try: + if "." in value: + return float(value) + return int(value) + except ValueError: + pass + + return value + + def list_settings(self): + """List all game settings.""" + settings = self.load_settings() + self._print_settings(settings) + + def _print_settings(self, obj, prefix=""): + """Recursively print settings.""" + if isinstance(obj, dict): + for key, value in obj.items(): + full_key = f"{prefix}.{key}" if prefix else key + if isinstance(value, dict): + print(f"\n[{full_key}]") + self._print_settings(value, full_key) + else: + print(f" {key}: {value}") + else: + print(f" {prefix}: {obj}") + + # ------------------------------------------------------------------------- + # Prompt Templates + # ------------------------------------------------------------------------- + + def list_prompts(self): + """List all prompt templates.""" + if not self.prompts_dir.exists(): + print("No prompts directory found.") + return + + print("\nPrompt Templates:") + for path in sorted(self.prompts_dir.glob("*.md")): + size = path.stat().st_size + print(f" {path.stem}: {size} bytes") + + def show_prompt(self, name: str): + """Show a prompt template.""" + path = self.prompts_dir / f"{name}.md" + if not path.exists(): + print(f"Prompt '{name}' not found.") + return + + print(f"=== {name}.md ===\n") + print(path.read_text()) + + def edit_prompt(self, name: str): + """Open prompt in default editor.""" + path = self.prompts_dir / f"{name}.md" + if not path.exists(): + # Create empty template + path.write_text(f"# {name.title()} Prompt\n\n") + + editor = os.environ.get("EDITOR", "nano") + os.system(f"{editor} {path}") + + # ------------------------------------------------------------------------- + # Maps + # ------------------------------------------------------------------------- + + def list_maps(self): + """List available maps.""" + maps_dir = self.data_dir / "maps" + if not maps_dir.exists(): + print("No maps directory found.") + return + + print("\nAvailable Maps:") + for path in sorted(maps_dir.glob("*.json")): + with open(path) as f: + data = json.load(f) + rooms = len(data.get("rooms", [])) + edges = len(data.get("edges", [])) + walls = len(data.get("walls", [])) + print(f" {path.stem}: {rooms} rooms, {edges} edges, {walls} walls") + + def show_map_info(self, name: str): + """Show detailed map information.""" + path = self.data_dir / "maps" / f"{name}.json" + if not path.exists(): + print(f"Map '{name}' not found.") + return + + with open(path) as f: + data = json.load(f) + + print(f"\n=== {name} Map ===\n") + + # Canvas size + if "canvas" in data: + print(f"Canvas: {data['canvas']['width']}x{data['canvas']['height']} pixels") + + # Rooms + print(f"\nRooms ({len(data.get('rooms', []))}):") + for room in data.get("rooms", []): + tasks = len(room.get("tasks", [])) + vent = "vent" if room.get("vent") else "" + print(f" {room['id']}: {room['name']} ({tasks} tasks) {vent}") + + # Spawn points + if data.get("spawn_points"): + print(f"\nSpawn Points: {', '.join(data['spawn_points'].keys())}") + + # ------------------------------------------------------------------------- + # Validation + # ------------------------------------------------------------------------- + + def validate(self): + """Validate all configurations.""" + errors = [] + warnings = [] + + # Check settings + settings = self.load_settings() + if not settings: + errors.append("Game settings file is empty or missing") + else: + # Validate ranges + speed = self._get_nested(settings, "player_speed") or self._get_nested(settings, "player.player_speed") + if speed and (speed < 50 or speed > 300): + warnings.append(f"player_speed={speed} is unusual (typical: 75-150)") + + impostors = self._get_nested(settings, "num_impostors") or self._get_nested(settings, "game.num_impostors") + if impostors and (impostors < 1 or impostors > 3): + warnings.append(f"num_impostors={impostors} is unusual (typical: 1-3)") + + # Check maps + maps_dir = self.data_dir / "maps" + if maps_dir.exists(): + for path in maps_dir.glob("*.json"): + try: + with open(path) as f: + data = json.load(f) + if not data.get("rooms"): + errors.append(f"Map {path.name} has no rooms") + except json.JSONDecodeError as e: + errors.append(f"Map {path.name} is invalid JSON: {e}") + + # Check prompts + if self.prompts_dir.exists(): + for path in self.prompts_dir.glob("*.md"): + content = path.read_text() + if len(content) < 50: + warnings.append(f"Prompt {path.name} is very short ({len(content)} chars)") + + # Report + print("\n=== Configuration Validation ===\n") + + if errors: + print("❌ Errors:") + for e in errors: + print(f" - {e}") + + if warnings: + print("\n⚠️ Warnings:") + for w in warnings: + print(f" - {w}") + + if not errors and not warnings: + print("✓ All configurations valid!") + + return len(errors) == 0 + + def _get_nested(self, d: dict, key: str): + """Get nested dict value.""" + parts = key.split(".") + for part in parts: + if isinstance(d, dict) and part in d: + d = d[part] + else: + return None + return d + + # ------------------------------------------------------------------------- + # Reset/Defaults + # ------------------------------------------------------------------------- + + def reset_settings(self): + """Reset settings to defaults.""" + defaults = { + "map_name": "skeld", + "num_impostors": 2, + "player_speed": 100.0, + "crewmate_vision": 1.0, + "impostor_vision": 1.5, + "kill_cooldown": 25.0, + "kill_distance": "medium", + "emergencies_per_player": 1, + "emergency_cooldown": 15.0, + "discussion_time": 30.0, + "voting_time": 120.0, + "confirm_ejects": True, + "anonymous_votes": False, + "visual_tasks": True, + "taskbar_updates": "always", + "common_tasks": 2, + "long_tasks": 1, + "short_tasks": 2, + "sabotage_cooldown": 30.0, + "reactor_timer": 45.0, + "o2_timer": 45.0, + "lights_vision_multiplier": 0.25, + "max_discussion_rounds": 20, + "convergence_threshold": 2 + } + + self.save_settings(defaults) + print("✓ Reset all settings to defaults") + + +def main(): + parser = argparse.ArgumentParser( + description="Glass Box League Configuration Manager", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s settings list # List all game settings + %(prog)s settings get kill_cooldown # Get a specific setting + %(prog)s settings set kill_cooldown 30 # Set a setting + %(prog)s settings reset # Reset to defaults + + %(prog)s prompts list # List prompt templates + %(prog)s prompts show action # Show action prompt + %(prog)s prompts edit discussion # Edit discussion prompt + + %(prog)s maps list # List available maps + %(prog)s maps info skeld # Show skeld map info + + %(prog)s validate # Validate all configs +""" + ) + + subparsers = parser.add_subparsers(dest="command", help="Command to run") + + # Settings commands + settings_parser = subparsers.add_parser("settings", help="Manage game settings") + settings_sub = settings_parser.add_subparsers(dest="action") + + settings_sub.add_parser("list", help="List all settings") + + get_parser = settings_sub.add_parser("get", help="Get a setting value") + get_parser.add_argument("key", help="Setting key (e.g., kill_cooldown)") + + set_parser = settings_sub.add_parser("set", help="Set a setting value") + set_parser.add_argument("key", help="Setting key") + set_parser.add_argument("value", help="New value") + + settings_sub.add_parser("reset", help="Reset to defaults") + + # Prompts commands + prompts_parser = subparsers.add_parser("prompts", help="Manage prompt templates") + prompts_sub = prompts_parser.add_subparsers(dest="action") + + prompts_sub.add_parser("list", help="List prompt templates") + + show_parser = prompts_sub.add_parser("show", help="Show a prompt") + show_parser.add_argument("name", help="Prompt name (without .md)") + + edit_parser = prompts_sub.add_parser("edit", help="Edit a prompt") + edit_parser.add_argument("name", help="Prompt name") + + # Maps commands + maps_parser = subparsers.add_parser("maps", help="Manage map data") + maps_sub = maps_parser.add_subparsers(dest="action") + + maps_sub.add_parser("list", help="List maps") + + info_parser = maps_sub.add_parser("info", help="Show map info") + info_parser.add_argument("name", help="Map name") + + # Validate command + subparsers.add_parser("validate", help="Validate all configurations") + + args = parser.parse_args() + manager = ConfigManager() + + if args.command == "settings": + if args.action == "list": + manager.list_settings() + elif args.action == "get": + value = manager.get_setting(args.key) + if value is not None: + print(f"{args.key} = {value}") + else: + print(f"Setting '{args.key}' not found") + elif args.action == "set": + manager.set_setting(args.key, args.value) + elif args.action == "reset": + manager.reset_settings() + else: + settings_parser.print_help() + + elif args.command == "prompts": + if args.action == "list": + manager.list_prompts() + elif args.action == "show": + manager.show_prompt(args.name) + elif args.action == "edit": + manager.edit_prompt(args.name) + else: + prompts_parser.print_help() + + elif args.command == "maps": + if args.action == "list": + manager.list_maps() + elif args.action == "info": + manager.show_map_info(args.name) + else: + maps_parser.print_help() + + elif args.command == "validate": + success = manager.validate() + sys.exit(0 if success else 1) + + else: + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/src/agents/prompt_assembler.py b/src/agents/prompt_assembler.py index 152d077..98e1923 100644 --- a/src/agents/prompt_assembler.py +++ b/src/agents/prompt_assembler.py @@ -77,7 +77,11 @@ Current settings: - Map: {map_name} - Impostors: {game_settings.get('num_impostors', 2)} - Kill cooldown: {game_settings.get('kill_cooldown', 25)}s -- Vision range: {game_settings.get('vision_range', 10)}m +- Kill distance: {game_settings.get('kill_distance', 'medium')} +- Discussion time: {game_settings.get('discussion_time', 30)}s +- Voting time: {game_settings.get('voting_time', 120)}s +- Confirm ejects: {'Yes' if game_settings.get('confirm_ejects', True) else 'No'} +- Visual tasks: {'On' if game_settings.get('visual_tasks', True) else 'Off'} Actions are taken by responding with JSON.""" diff --git a/src/engine/game.py b/src/engine/game.py index 76125df..ce087a6 100644 --- a/src/engine/game.py +++ b/src/engine/game.py @@ -25,45 +25,70 @@ from src.map.graph import GameMap @dataclass class GameConfig: - """Game configuration loaded from YAML. All values are modular.""" - # Game setup + """ + Complete Among Us lobby settings. + All values match the in-game settings panel. + """ + # === Game Setup === map_name: str = "skeld" - min_players: int = 4 - max_players: int = 15 num_impostors: int = 2 - # Player stats (can be overridden per-player) - player_speed: float = 1.5 # meters per second - vision_range: float = 10.0 # meters - crewmate_vision: float = 1.0 # multiplier + # === Player Settings === + player_speed: float = 100.0 # pixels per second (1.0x = 100px/s) + crewmate_vision: float = 1.0 # multiplier (1.0x = 300px radius) impostor_vision: float = 1.5 # multiplier - # Impostor mechanics + # === Kill Settings === kill_cooldown: float = 25.0 # seconds - kill_range: float = 2.0 # meters + kill_distance: str = "medium" # "short" (50px), "medium" (100px), "long" (150px) - # Meeting settings - emergency_cooldown: float = 15.0 # seconds + # === Meeting Settings === emergencies_per_player: int = 1 - discussion_time: float = 30.0 # seconds (informational for LLMs) - voting_time: float = 60.0 # seconds (informational for LLMs) - confirm_ejects: bool = True - anonymous_votes: bool = False + emergency_cooldown: float = 15.0 # seconds + discussion_time: float = 30.0 # seconds + voting_time: float = 120.0 # seconds + confirm_ejects: bool = True # "Red was The Impostor" or "Red was ejected" + anonymous_votes: bool = False # hide who voted for whom - # Sabotage settings - o2_timer: float = 30.0 - reactor_timer: float = 30.0 - lights_vision_multiplier: float = 0.25 + # === Task Settings === + visual_tasks: bool = True # can see others doing visual tasks (medbay scan, etc) + taskbar_updates: str = "always" # "always", "meetings", "never" + common_tasks: int = 2 + long_tasks: int = 1 + short_tasks: int = 2 - # Task settings - tasks_short: int = 2 - tasks_long: int = 1 - tasks_common: int = 2 + # === Sabotage Settings === + sabotage_cooldown: float = 30.0 # seconds between sabotages + reactor_timer: float = 45.0 # seconds to fix reactor + o2_timer: float = 45.0 # seconds to fix O2 + lights_vision_multiplier: float = 0.25 # vision during lights sabotage - # LLM-specific + # === LLM-Specific (not in actual game) === max_discussion_rounds: int = 20 convergence_threshold: int = 2 + @property + def kill_distance_pixels(self) -> float: + """Convert kill distance setting to pixels.""" + distances = {"short": 50.0, "medium": 100.0, "long": 150.0} + return distances.get(self.kill_distance, 100.0) + + @property + def vision_radius(self) -> float: + """Base vision radius in pixels.""" + return 300.0 + + def get_crewmate_vision_radius(self, lights_sabotaged: bool = False) -> float: + """Get actual crewmate vision radius.""" + base = self.vision_radius * self.crewmate_vision + if lights_sabotaged: + return base * self.lights_vision_multiplier + return base + + def get_impostor_vision_radius(self, lights_sabotaged: bool = False) -> float: + """Get actual impostor vision radius (not affected by lights).""" + return self.vision_radius * self.impostor_vision + @classmethod def load(cls, path: str) -> "GameConfig": """Load config from YAML or JSON file.""" @@ -88,28 +113,25 @@ class GameConfig: # Flatten nested structure from YAML mappings = { - "game": ["map_name", "min_players", "max_players", "num_impostors"], - "player": ["player_speed", "vision_range", "crewmate_vision", "impostor_vision"], - "impostor": ["kill_cooldown", "kill_range"], + "game": ["map_name", "num_impostors"], + "player": ["player_speed", "crewmate_vision", "impostor_vision"], + "impostor": ["kill_cooldown", "kill_distance"], "meeting": ["emergency_cooldown", "emergencies_per_player", "discussion_time", "voting_time", "confirm_ejects", "anonymous_votes"], - "sabotage": ["o2_timer", "reactor_timer", "lights_vision_multiplier"], - "crewmate": ["tasks_short", "tasks_long", "tasks_common"], + "sabotage": ["sabotage_cooldown", "o2_timer", "reactor_timer", "lights_vision_multiplier"], + "tasks": ["visual_tasks", "taskbar_updates", "common_tasks", "long_tasks", "short_tasks"], "llm": ["max_discussion_rounds", "convergence_threshold"] } for section, keys in mappings.items(): section_data = data.get(section, {}) for key in keys: - # Handle key name variations - yaml_key = key.replace(f"{section}_", "").replace("player_", "") - if key == "map_name": - yaml_key = "map" - elif key == "player_speed": - yaml_key = "speed" - - if yaml_key in section_data: - setattr(config, key, section_data[yaml_key]) + # Check if key exists in section data directly + if key in section_data: + setattr(config, key, section_data[key]) + # Also check top-level for flat JSON configs + elif key in data: + setattr(config, key, data[key]) return config @@ -170,20 +192,20 @@ class GameEngine: player_id: str, name: str, color: str, - role: Role = Role.CREWMATE, - speed: Optional[float] = None, - vision: Optional[float] = None + role: Role = Role.CREWMATE ) -> Player: - """Add a player to the game. All stats are configurable.""" + """Add a player to the game. Speed is game-wide constant.""" is_impostor = role == Role.IMPOSTOR + # Get spawn point from map + spawn = self.map.spawn_points.get("cafeteria", Position(x=500, y=200)) + player = Player( id=player_id, name=name, color=color, role=role, - position=Position(room_id="cafeteria"), - speed=speed or self.config.player_speed, + position=Position(x=spawn.x, y=spawn.y, room_id="cafeteria"), kill_cooldown=self.config.kill_cooldown if is_impostor else 0.0 ) @@ -299,7 +321,7 @@ class GameEngine: player.path = path total_distance = self.map.path_distance(path) - travel_time = total_distance / player.speed + travel_time = total_distance / self.config.player_speed self.simulator.schedule_in(travel_time, "PLAYER_MOVE_COMPLETE", { "player_id": player_id, diff --git a/src/engine/path_utils.py b/src/engine/path_utils.py new file mode 100644 index 0000000..cc90d17 --- /dev/null +++ b/src/engine/path_utils.py @@ -0,0 +1,248 @@ +""" +The Glass Box League — Path Utilities + +Utilities for consistent walk speed interpolation along paths. +Critical for rendering without teleportation artifacts. +""" + +from dataclasses import dataclass, field +from typing import Optional +import math + +from .types import Position + + +@dataclass +class PathSegment: + """A segment of a walking path with distance info.""" + start: Position + end: Position + distance: float # Euclidean distance + cumulative_distance: float # Distance from path start to segment end + + def interpolate(self, t: float) -> Position: + """ + Interpolate position along this segment. + + Args: + t: 0.0 = start, 1.0 = end + """ + return Position( + x=self.start.x + (self.end.x - self.start.x) * t, + y=self.start.y + (self.end.y - self.start.y) * t, + room_id=None # Will be recalculated by engine + ) + + +@dataclass +class WalkPath: + """ + A complete walking path with distance tracking. + + Enables smooth interpolation at constant speed. + """ + waypoints: list[Position] + segments: list[PathSegment] = field(default_factory=list) + total_distance: float = 0.0 + + def __post_init__(self): + """Calculate segments from waypoints.""" + if len(self.waypoints) < 2: + return + + cumulative = 0.0 + self.segments = [] + + for i in range(len(self.waypoints) - 1): + start = self.waypoints[i] + end = self.waypoints[i + 1] + dist = start.distance_to(end) + cumulative += dist + + self.segments.append(PathSegment( + start=start, + end=end, + distance=dist, + cumulative_distance=cumulative + )) + + self.total_distance = cumulative + + def position_at_distance(self, traveled: float) -> Position: + """ + Get position after traveling a certain distance along the path. + + Args: + traveled: Distance traveled from path start (pixels) + + Returns: + Interpolated position + """ + if not self.segments: + return self.waypoints[0] if self.waypoints else Position() + + # Clamp to path bounds + if traveled <= 0: + return self.waypoints[0] + if traveled >= self.total_distance: + return self.waypoints[-1] + + # Find which segment we're in + for segment in self.segments: + if traveled <= segment.cumulative_distance: + # How far into this segment? + prev_cumulative = segment.cumulative_distance - segment.distance + segment_traveled = traveled - prev_cumulative + t = segment_traveled / segment.distance if segment.distance > 0 else 0 + return segment.interpolate(t) + + # Fallback (shouldn't reach here) + return self.waypoints[-1] + + def position_at_time(self, elapsed: float, speed: float) -> Position: + """ + Get position after elapsed time at given speed. + + Args: + elapsed: Time elapsed (seconds) + speed: Walk speed (pixels/second) + + Returns: + Interpolated position + """ + traveled = elapsed * speed + return self.position_at_distance(traveled) + + def time_to_complete(self, speed: float) -> float: + """Time required to walk the entire path at given speed.""" + return self.total_distance / speed if speed > 0 else float('inf') + + def is_complete(self, elapsed: float, speed: float) -> bool: + """Check if path is fully walked after elapsed time.""" + return elapsed * speed >= self.total_distance + + def distance_at_time(self, elapsed: float, speed: float) -> float: + """Distance traveled after elapsed time.""" + return min(elapsed * speed, self.total_distance) + + def progress(self, elapsed: float, speed: float) -> float: + """Progress along path as 0.0-1.0.""" + if self.total_distance == 0: + return 1.0 + return min(elapsed * speed / self.total_distance, 1.0) + + @classmethod + def from_positions(cls, positions: list[Position]) -> "WalkPath": + """Create path from list of positions.""" + return cls(waypoints=positions) + + @classmethod + def direct(cls, start: Position, end: Position) -> "WalkPath": + """Create direct path between two points.""" + return cls(waypoints=[start, end]) + + +@dataclass +class WalkState: + """ + Tracks a player's current walk state. + + Used by engine to update positions each tick. + """ + player_id: str + path: WalkPath + start_time: float # Game time when walk started + speed: float # pixels/second + + def current_position(self, current_time: float) -> Position: + """Get current position at given game time.""" + elapsed = current_time - self.start_time + return self.path.position_at_time(elapsed, self.speed) + + def is_complete(self, current_time: float) -> bool: + """Check if walk is complete at given time.""" + elapsed = current_time - self.start_time + return self.path.is_complete(elapsed, self.speed) + + def arrival_time(self) -> float: + """Get game time when walk completes.""" + return self.start_time + self.path.time_to_complete(self.speed) + + def progress(self, current_time: float) -> float: + """Get progress 0.0-1.0 at given time.""" + elapsed = current_time - self.start_time + return self.path.progress(elapsed, self.speed) + + +class WalkManager: + """ + Manages active walks for all players. + + Provides frame-accurate position queries for rendering. + """ + + def __init__(self): + self._active_walks: dict[str, WalkState] = {} + + def start_walk(self, player_id: str, path: WalkPath, + start_time: float, speed: float) -> WalkState: + """Start a new walk for a player.""" + state = WalkState( + player_id=player_id, + path=path, + start_time=start_time, + speed=speed + ) + self._active_walks[player_id] = state + return state + + def get_position(self, player_id: str, current_time: float) -> Optional[Position]: + """ + Get player's current position. + + Returns None if player has no active walk. + """ + state = self._active_walks.get(player_id) + if not state: + return None + return state.current_position(current_time) + + def get_walk_state(self, player_id: str) -> Optional[WalkState]: + """Get player's current walk state.""" + return self._active_walks.get(player_id) + + def is_walking(self, player_id: str, current_time: float) -> bool: + """Check if player is currently walking.""" + state = self._active_walks.get(player_id) + if not state: + return False + return not state.is_complete(current_time) + + def cancel_walk(self, player_id: str): + """Cancel a player's walk.""" + self._active_walks.pop(player_id, None) + + def get_completed_walks(self, current_time: float) -> list[WalkState]: + """Get list of walks that completed by current_time.""" + completed = [] + for player_id, state in list(self._active_walks.items()): + if state.is_complete(current_time): + completed.append(state) + return completed + + def cleanup_completed(self, current_time: float) -> list[WalkState]: + """Remove completed walks and return them.""" + completed = [] + for player_id in list(self._active_walks.keys()): + state = self._active_walks[player_id] + if state.is_complete(current_time): + completed.append(state) + del self._active_walks[player_id] + return completed + + def get_all_positions(self, current_time: float) -> dict[str, Position]: + """Get positions of all walking players at current_time.""" + return { + player_id: state.current_position(current_time) + for player_id, state in self._active_walks.items() + } diff --git a/src/engine/simulator.py b/src/engine/simulator.py index 8a3b3bc..5d1984f 100644 --- a/src/engine/simulator.py +++ b/src/engine/simulator.py @@ -131,7 +131,7 @@ class Simulator: """Get all living players in a specific room.""" return [ p for p in self.get_living_players() - if p.position.is_in_room() and p.position.room_id == room_id + if p.position.room_id == room_id ] def bodies_at(self, room_id: str) -> list[Body]: diff --git a/src/engine/types.py b/src/engine/types.py index 3051535..935063b 100644 --- a/src/engine/types.py +++ b/src/engine/types.py @@ -2,12 +2,14 @@ The Glass Box League — Core Types Fundamental data structures for the discrete event simulator. +Now with pixel-based positions for ray-traced FOV. """ from dataclasses import dataclass, field from enum import Enum, auto from typing import Optional import uuid +import math class Role(Enum): @@ -26,21 +28,48 @@ class GamePhase(Enum): @dataclass class Position: """ - A position in the game world. + A pixel-based position in the game world. - Can be: - - In a room: room_id is set, edge_id is None - - On an edge: edge_id is set, progress is 0.0-1.0 + Coordinates are in pixels on the map image. + Room ID is derived from position via polygon containment. """ + x: float = 0.0 + y: float = 0.0 + + # Derived from position (set by engine) room_id: Optional[str] = None - edge_id: Optional[str] = None - progress: float = 0.0 # 0.0 = start of edge, 1.0 = end - def is_in_room(self) -> bool: - return self.room_id is not None and self.edge_id is None + def distance_to(self, other: "Position") -> float: + """Euclidean distance to another position.""" + return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2) - def is_on_edge(self) -> bool: - return self.edge_id is not None + def direction_to(self, other: "Position") -> tuple[float, float]: + """Unit vector pointing toward another position.""" + dist = self.distance_to(other) + if dist == 0: + return (0.0, 0.0) + return ((other.x - self.x) / dist, (other.y - self.y) / dist) + + def move_toward(self, target: "Position", distance: float) -> "Position": + """Return new position moved toward target by distance.""" + dir_x, dir_y = self.direction_to(target) + return Position( + x=self.x + dir_x * distance, + y=self.y + dir_y * distance, + room_id=None # Will be recalculated + ) + + def to_tuple(self) -> tuple[float, float]: + return (self.x, self.y) + + @classmethod + def from_tuple(cls, t: tuple[float, float]) -> "Position": + return cls(x=t[0], y=t[1]) + + def __eq__(self, other) -> bool: + if not isinstance(other, Position): + return False + return abs(self.x - other.x) < 0.01 and abs(self.y - other.y) < 0.01 @dataclass @@ -50,14 +79,14 @@ class Player: name: str color: str role: Role = Role.CREWMATE - position: Position = field(default_factory=lambda: Position(room_id="cafeteria")) + position: Position = field(default_factory=Position) is_alive: bool = True - speed: float = 1.0 # meters per second + # Speed is a game-wide constant in GameConfig, not per-player - # Movement intent - destination: Optional[str] = None # Target room_id - path: list[str] = field(default_factory=list) # Sequence of edge_ids + # Movement + destination: Optional[Position] = None # Target position + path: list[Position] = field(default_factory=list) # Waypoints # Task state current_task: Optional[str] = None @@ -69,7 +98,7 @@ class Player: kill_cooldown: float = 0.0 # Trigger muting - muted_triggers: dict[str, float] = field(default_factory=dict) # trigger_type -> until_time + muted_triggers: dict[str, float] = field(default_factory=dict) @dataclass @@ -97,3 +126,17 @@ class Event: def __lt__(self, other: "Event") -> bool: return self.time < other.time + + +@dataclass +class Wall: + """A wall segment that blocks vision.""" + p1: tuple[float, float] + p2: tuple[float, float] + + def to_dict(self) -> dict: + return {"p1": list(self.p1), "p2": list(self.p2)} + + @classmethod + def from_dict(cls, data: dict) -> "Wall": + return cls(p1=tuple(data["p1"]), p2=tuple(data["p2"])) diff --git a/src/engine/vision_raycast.py b/src/engine/vision_raycast.py new file mode 100644 index 0000000..b846b78 --- /dev/null +++ b/src/engine/vision_raycast.py @@ -0,0 +1,313 @@ +""" +The Glass Box League — Ray-Traced Vision System + +Line-of-sight visibility using raycasting against wall segments. +""" + +from dataclasses import dataclass +from typing import Optional +import math + +from .types import Position, Wall, Player + + +@dataclass +class VisibilityResult: + """Result of visibility check.""" + visible: bool + distance: float + blocked_by_wall: bool = False + + +class RaycastVision: + """ + Vision system using raycasting for line-of-sight. + + Handles: + - Impostor vs Crewmate vision (impostors see further) + - Lights sabotage (only affects crewmates, not impostors) + - Wall occlusion + """ + + def __init__( + self, + walls: list[Wall], + base_vision_radius: float = 300.0, + crewmate_vision: float = 1.0, + impostor_vision: float = 1.5, + lights_multiplier: float = 0.25 + ): + """ + Args: + walls: List of wall segments that block vision + base_vision_radius: Base vision distance in pixels + crewmate_vision: Crewmate vision multiplier + impostor_vision: Impostor vision multiplier + lights_multiplier: Vision reduction during lights sabotage + """ + self.walls = walls + self.base_vision_radius = base_vision_radius + self.crewmate_vision = crewmate_vision + self.impostor_vision = impostor_vision + self.lights_multiplier = lights_multiplier + self.lights_sabotaged = False + + # Legacy support + self.vision_radius = base_vision_radius + + def get_vision_radius_for_player(self, player: Player) -> float: + """ + Get vision radius for a specific player. + + - Impostors: base * impostor_vision (NOT affected by lights) + - Crewmates: base * crewmate_vision (affected by lights) + """ + from .types import Role + + if player.role == Role.IMPOSTOR: + # Impostors have better vision and are NOT affected by lights sabotage + return self.base_vision_radius * self.impostor_vision + else: + # Crewmates are affected by lights sabotage + base = self.base_vision_radius * self.crewmate_vision + if self.lights_sabotaged: + return base * self.lights_multiplier + return base + + def set_lights_sabotaged(self, sabotaged: bool): + """Set lights sabotage state. Only affects crewmate vision.""" + self.lights_sabotaged = sabotaged + + def line_segment_intersection( + self, + ray_start: tuple[float, float], + ray_end: tuple[float, float], + wall_p1: tuple[float, float], + wall_p2: tuple[float, float] + ) -> Optional[tuple[float, float]]: + """ + Check if ray intersects wall segment. + + Returns intersection point if exists, None otherwise. + Uses parametric line intersection. + """ + x1, y1 = ray_start + x2, y2 = ray_end + x3, y3 = wall_p1 + x4, y4 = wall_p2 + + denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4) + + if abs(denom) < 1e-10: + return None # Parallel lines + + t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom + u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom + + # Check if intersection is within both line segments + if 0 <= t <= 1 and 0 <= u <= 1: + ix = x1 + t * (x2 - x1) + iy = y1 + t * (y2 - y1) + return (ix, iy) + + return None + + def is_line_blocked( + self, + from_pos: tuple[float, float], + to_pos: tuple[float, float] + ) -> bool: + """Check if any wall blocks the line between two points.""" + for wall in self.walls: + if self.line_segment_intersection(from_pos, to_pos, wall.p1, wall.p2): + return True + return False + + def check_visibility( + self, + observer: Position, + target: Position, + vision_radius: float = None + ) -> VisibilityResult: + """ + Check if target is visible from observer position. + + Target is visible if: + 1. Within vision radius + 2. No walls blocking line-of-sight + """ + radius = vision_radius or self.vision_radius + distance = observer.distance_to(target) + + # Beyond vision radius + if distance > radius: + return VisibilityResult(visible=False, distance=distance) + + # Check for wall occlusion + blocked = self.is_line_blocked(observer.to_tuple(), target.to_tuple()) + + return VisibilityResult( + visible=not blocked, + distance=distance, + blocked_by_wall=blocked + ) + + def get_visible_players( + self, + observer: Player, + all_players: list[Player], + include_dead: bool = False + ) -> list[tuple[Player, float]]: + """ + Get all players visible to observer. + + Uses observer's role to determine vision radius. + Returns list of (player, distance) tuples for visible players. + """ + visible = [] + vision_radius = self.get_vision_radius_for_player(observer) + + for player in all_players: + # Skip self + if player.id == observer.id: + continue + + # Skip dead unless requested + if not player.is_alive and not include_dead: + continue + + result = self.check_visibility( + observer.position, + player.position, + vision_radius=vision_radius + ) + + if result.visible: + visible.append((player, result.distance)) + + # Sort by distance + visible.sort(key=lambda x: x[1]) + return visible + + def get_visible_bodies( + self, + observer: Player, + bodies: list + ) -> list[tuple]: + """Get all bodies visible to observer.""" + visible = [] + + for body in bodies: + result = self.check_visibility(observer.position, body.position) + if result.visible: + visible.append((body, result.distance)) + + visible.sort(key=lambda x: x[1]) + return visible + + def cast_rays_for_polygon( + self, + observer: Position, + num_rays: int = 360 + ) -> list[tuple[float, float]]: + """ + Cast rays in all directions to build vision polygon. + + Used for rendering the visible area. + Returns list of points forming the vision boundary. + """ + points = [] + + for i in range(num_rays): + angle = 2 * math.pi * i / num_rays + + # Ray direction + dx = math.cos(angle) + dy = math.sin(angle) + + # Default to vision radius + ray_end = ( + observer.x + dx * self.vision_radius, + observer.y + dy * self.vision_radius + ) + + # Find closest intersection with any wall + closest_dist = self.vision_radius + closest_point = ray_end + + for wall in self.walls: + intersection = self.line_segment_intersection( + observer.to_tuple(), + ray_end, + wall.p1, + wall.p2 + ) + + if intersection: + dist = math.sqrt( + (intersection[0] - observer.x)**2 + + (intersection[1] - observer.y)**2 + ) + if dist < closest_dist: + closest_dist = dist + closest_point = intersection + + points.append(closest_point) + + return points + + def update_vision_radius(self, new_radius: float): + """Update base vision radius.""" + self.base_vision_radius = new_radius + self.vision_radius = new_radius + + def add_wall(self, wall: Wall): + """Add a wall segment.""" + self.walls.append(wall) + + def clear_walls(self): + """Remove all walls.""" + self.walls = [] + + @classmethod + def from_map_data(cls, map_data: dict, config: dict = None) -> "RaycastVision": + """ + Create from map JSON data and optional game config. + + Args: + map_data: Map JSON with walls + config: Game config dict with vision settings + """ + walls = [ + Wall.from_dict(w) for w in map_data.get("walls", []) + ] + + # Use config if provided, otherwise defaults + if config: + return cls( + walls=walls, + base_vision_radius=config.get("vision_radius", 300.0), + crewmate_vision=config.get("crewmate_vision", 1.0), + impostor_vision=config.get("impostor_vision", 1.5), + lights_multiplier=config.get("lights_vision_multiplier", 0.25) + ) + + return cls(walls=walls) + + @classmethod + def from_game_config(cls, walls: list[Wall], game_config) -> "RaycastVision": + """ + Create from Wall list and GameConfig object. + + Args: + walls: Wall segments + game_config: GameConfig instance + """ + return cls( + walls=walls, + base_vision_radius=game_config.vision_radius, + crewmate_vision=game_config.crewmate_vision, + impostor_vision=game_config.impostor_vision, + lights_multiplier=game_config.lights_vision_multiplier + ) + diff --git a/src/map/graph.py b/src/map/graph.py index acbf844..40de3c1 100644 --- a/src/map/graph.py +++ b/src/map/graph.py @@ -1,12 +1,15 @@ """ The Glass Box League — Map Model -Continuous node graph with distances for position tracking. +Continuous node graph with pixel coordinates for ray-traced vision. """ from dataclasses import dataclass, field from typing import Optional import json +import math + +from ..engine.types import Position, Wall @dataclass @@ -15,6 +18,7 @@ class Task: id: str name: str duration: float # seconds to complete + position: Position = field(default_factory=Position) is_visual: bool = False # Can others see you doing it? @@ -23,6 +27,7 @@ class Vent: """A vent connection point.""" id: str connects_to: list[str] # Other vent IDs + position: Position = field(default_factory=Position) @dataclass @@ -30,13 +35,20 @@ class Room: """A room (node) in the map.""" id: str name: str + center: Position = field(default_factory=Position) + bounds: tuple[Position, Position] = None # Top-left, bottom-right tasks: list[Task] = field(default_factory=list) vent: Optional[Vent] = None + emergency_button: Optional[Position] = None - # Position within the room (for spawn points, task locations) - # Simplified: just a single point for now - x: float = 0.0 - y: float = 0.0 + def contains_point(self, pos: Position) -> bool: + """Check if a position is within this room's bounds.""" + if self.bounds is None: + # Fallback: circular area around center + return self.center.distance_to(pos) < 100 + + tl, br = self.bounds + return (tl.x <= pos.x <= br.x and tl.y <= pos.y <= br.y) @dataclass @@ -45,11 +57,18 @@ class Edge: id: str room_a: str # Room ID room_b: str # Room ID - distance: float # meters + waypoints: list[Position] = field(default_factory=list) - # Path geometry (list of waypoints for LoS calculation) - # Each waypoint is (x, y) - waypoints: list[tuple[float, float]] = field(default_factory=list) + @property + def distance(self) -> float: + """Calculate total path distance through waypoints.""" + if not self.waypoints: + return 0.0 + + total = 0.0 + for i in range(len(self.waypoints) - 1): + total += self.waypoints[i].distance_to(self.waypoints[i + 1]) + return total def other_room(self, room_id: str) -> str: """Get the room on the other end of this edge.""" @@ -60,12 +79,20 @@ class GameMap: """ The game map: a graph of rooms connected by edges. - Supports pathfinding, distance calculation, and visibility queries. + Now with pixel coordinates and wall geometry for ray-traced vision. """ def __init__(self): + self.name: str = "" + self.width: int = 2000 + self.height: int = 1500 + self.vision_radius: float = 300.0 + self.vision_radius_sabotaged: float = 150.0 + self.rooms: dict[str, Room] = {} self.edges: dict[str, Edge] = {} + self.walls: list[Wall] = [] + self.spawn_points: dict[str, Position] = {} # Adjacency list: room_id -> list of (edge_id, neighbor_room_id) self._adjacency: dict[str, list[tuple[str, str]]] = {} @@ -89,10 +116,21 @@ class GameMap: self._adjacency[edge.room_a].append((edge.id, edge.room_b)) self._adjacency[edge.room_b].append((edge.id, edge.room_a)) + def add_wall(self, wall: Wall) -> None: + """Add a wall segment.""" + self.walls.append(wall) + def get_room(self, room_id: str) -> Optional[Room]: """Get a room by ID.""" return self.rooms.get(room_id) + def get_room_at(self, pos: Position) -> Optional[Room]: + """Find which room contains a position.""" + for room in self.rooms.values(): + if room.contains_point(pos): + return room + return None + def get_edge(self, edge_id: str) -> Optional[Edge]: """Get an edge by ID.""" return self.edges.get(edge_id) @@ -108,6 +146,17 @@ class GameMap: return self.edges[edge_id] return None + def get_spawn_position(self, room_id: str = "cafeteria") -> Position: + """Get a spawn position, defaulting to cafeteria.""" + if room_id in self.spawn_points: + return self.spawn_points[room_id] + if "cafeteria" in self.spawn_points: + return self.spawn_points["cafeteria"] + # Fallback to room center + if room_id in self.rooms: + return self.rooms[room_id].center + return Position(x=self.width / 2, y=self.height / 2) + # --- Pathfinding --- def find_path(self, from_room: str, to_room: str) -> Optional[list[str]]: @@ -144,6 +193,46 @@ class GameMap: return None + def get_path_waypoints(self, from_pos: Position, to_room: str) -> list[Position]: + """ + Get full path from a position to a room center. + + Returns list of waypoints to walk through. + """ + current_room = self.get_room_at(from_pos) + if not current_room: + return [self.rooms[to_room].center] if to_room in self.rooms else [] + + edge_ids = self.find_path(current_room.id, to_room) + if edge_ids is None: + return [] + + waypoints = [from_pos] # Start from current position + for edge_id in edge_ids: + edge = self.edges[edge_id] + waypoints.extend(edge.waypoints) + + # Add destination room center + if to_room in self.rooms: + waypoints.append(self.rooms[to_room].center) + + return waypoints + + def create_walk_path(self, from_pos: Position, to_room: str): + """ + Create a WalkPath for walking from position to room. + + Returns a WalkPath object with proper segment distances + for frame-accurate interpolation. + """ + from ..engine.path_utils import WalkPath + + waypoints = self.get_path_waypoints(from_pos, to_room) + if not waypoints: + return WalkPath(waypoints=[from_pos]) + + return WalkPath(waypoints=waypoints) + def path_distance(self, edge_ids: list[str]) -> float: """Calculate total distance of a path.""" return sum(self.edges[eid].distance for eid in edge_ids) @@ -153,14 +242,20 @@ class GameMap: def to_dict(self) -> dict: """Serialize map to dictionary.""" return { + "name": self.name, + "width": self.width, + "height": self.height, + "vision_radius": self.vision_radius, + "vision_radius_sabotaged": self.vision_radius_sabotaged, + "spawn_points": {k: [v.x, v.y] for k, v in self.spawn_points.items()}, "rooms": [ { "id": r.id, "name": r.name, - "x": r.x, - "y": r.y, - "tasks": [{"id": t.id, "name": t.name, "duration": t.duration} for t in r.tasks], - "vent": {"id": r.vent.id, "connects_to": r.vent.connects_to} if r.vent else None + "center": [r.center.x, r.center.y], + "bounds": [[r.bounds[0].x, r.bounds[0].y], [r.bounds[1].x, r.bounds[1].y]] if r.bounds else None, + "tasks": [{"id": t.id, "name": t.name, "duration": t.duration, "position": [t.position.x, t.position.y]} for t in r.tasks], + "vent": {"id": r.vent.id, "connects_to": r.vent.connects_to, "position": [r.vent.position.x, r.vent.position.y]} if r.vent else None } for r in self.rooms.values() ], @@ -169,11 +264,11 @@ class GameMap: "id": e.id, "room_a": e.room_a, "room_b": e.room_b, - "distance": e.distance, - "waypoints": e.waypoints + "waypoints": [[p.x, p.y] for p in e.waypoints] } for e in self.edges.values() - ] + ], + "walls": [w.to_dict() for w in self.walls] } def save(self, path: str) -> None: @@ -188,21 +283,76 @@ class GameMap: data = json.load(f) game_map = cls() + game_map.name = data.get("name", "") + game_map.width = data.get("width", 2000) + game_map.height = data.get("height", 1500) + game_map.vision_radius = data.get("vision_radius", 300.0) + game_map.vision_radius_sabotaged = data.get("vision_radius_sabotaged", 150.0) - for r in data["rooms"]: - tasks = [Task(id=t["id"], name=t["name"], duration=t["duration"]) for t in r.get("tasks", [])] - vent = Vent(id=r["vent"]["id"], connects_to=r["vent"]["connects_to"]) if r.get("vent") else None - room = Room(id=r["id"], name=r["name"], x=r.get("x", 0), y=r.get("y", 0), tasks=tasks, vent=vent) + # Spawn points + for room_id, pos in data.get("spawn_points", {}).items(): + game_map.spawn_points[room_id] = Position(x=pos[0], y=pos[1]) + + # Rooms + for r in data.get("rooms", []): + center = r.get("center", [0, 0]) + bounds_data = r.get("bounds") + bounds = None + if bounds_data: + bounds = ( + Position(x=bounds_data[0][0], y=bounds_data[0][1]), + Position(x=bounds_data[1][0], y=bounds_data[1][1]) + ) + + tasks = [] + for t in r.get("tasks", []): + pos = t.get("position", [0, 0]) + tasks.append(Task( + id=t["id"], + name=t["name"], + duration=t["duration"], + position=Position(x=pos[0], y=pos[1]) + )) + + vent = None + if r.get("vent"): + v = r["vent"] + pos = v.get("position", [0, 0]) + vent = Vent( + id=v["id"], + connects_to=v["connects_to"], + position=Position(x=pos[0], y=pos[1]) + ) + + emergency = None + if r.get("emergency_button"): + eb = r["emergency_button"] + emergency = Position(x=eb[0], y=eb[1]) + + room = Room( + id=r["id"], + name=r["name"], + center=Position(x=center[0], y=center[1]), + bounds=bounds, + tasks=tasks, + vent=vent, + emergency_button=emergency + ) game_map.add_room(room) - for e in data["edges"]: + # Edges + for e in data.get("edges", []): + waypoints = [Position(x=w[0], y=w[1]) for w in e.get("waypoints", [])] edge = Edge( id=e["id"], room_a=e["room_a"], room_b=e["room_b"], - distance=e["distance"], - waypoints=[tuple(w) for w in e.get("waypoints", [])] + waypoints=waypoints ) game_map.add_edge(edge) + # Walls + for w in data.get("walls", []): + game_map.add_wall(Wall.from_dict(w)) + return game_map diff --git a/tests/test_game.py b/tests/test_game.py index 7a122ee..5c10cd3 100644 --- a/tests/test_game.py +++ b/tests/test_game.py @@ -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() diff --git a/tests/test_map.py b/tests/test_map.py index aa25fb1..a329ad2 100644 --- a/tests/test_map.py +++ b/tests/test_map.py @@ -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__": diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py new file mode 100644 index 0000000..9628127 --- /dev/null +++ b/tests/test_path_utils.py @@ -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() diff --git a/tests/test_simulator.py b/tests/test_simulator.py index 7ee19e2..8c943bd 100644 --- a/tests/test_simulator.py +++ b/tests/test_simulator.py @@ -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) diff --git a/tests/test_vision_raycast.py b/tests/test_vision_raycast.py new file mode 100644 index 0000000..7171e73 --- /dev/null +++ b/tests/test_vision_raycast.py @@ -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() +