- Core engine: simulator, game mechanics, triggers (138 tests) - Fog-of-war per-player state tracking - Meeting flow: interrupt, discussion, voting, consolidation - Prompt assembler with strategy injection tiers - LLM client with fallbacks for models without JSON/system support - Prompt templates: action, discussion, voting, reflection - Full integration in main.py orchestrator - Verified working with free OpenRouter models (Gemma)
484 lines
17 KiB
Python
484 lines
17 KiB
Python
"""
|
|
Tests for the game engine.
|
|
"""
|
|
|
|
import unittest
|
|
import sys
|
|
import os
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
|
|
from src.engine.game import GameEngine, GameConfig
|
|
from src.engine.types import Player, Position, Role, GamePhase, Body
|
|
from src.map.graph import GameMap, Room, Edge, Task, Vent
|
|
|
|
|
|
def create_simple_map():
|
|
"""Create a simple test map."""
|
|
game_map = GameMap()
|
|
|
|
# Cafeteria with task
|
|
game_map.add_room(Room(
|
|
id="cafeteria",
|
|
name="Cafeteria",
|
|
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0)]
|
|
))
|
|
|
|
# Electrical with vent
|
|
elec_vent = Vent(id="vent_elec", connects_to=["vent_security"])
|
|
game_map.add_room(Room(
|
|
id="electrical",
|
|
name="Electrical",
|
|
vent=elec_vent,
|
|
tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0)]
|
|
))
|
|
|
|
# Security with vent
|
|
sec_vent = Vent(id="vent_security", connects_to=["vent_elec"])
|
|
game_map.add_room(Room(
|
|
id="security",
|
|
name="Security",
|
|
vent=sec_vent
|
|
))
|
|
|
|
# Admin
|
|
game_map.add_room(Room(id="admin", name="Admin"))
|
|
|
|
# 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))
|
|
|
|
return game_map
|
|
|
|
|
|
class TestGameConfig(unittest.TestCase):
|
|
"""Tests for GameConfig."""
|
|
|
|
def test_default_config(self):
|
|
config = GameConfig()
|
|
self.assertEqual(config.num_impostors, 2)
|
|
self.assertEqual(config.kill_cooldown, 25.0)
|
|
self.assertEqual(config.emergencies_per_player, 1)
|
|
|
|
def test_config_to_dict(self):
|
|
config = GameConfig()
|
|
data = config.to_dict()
|
|
self.assertIn("num_impostors", data)
|
|
self.assertIn("kill_cooldown", data)
|
|
|
|
|
|
class TestGameEngineSetup(unittest.TestCase):
|
|
"""Tests for game engine initialization and player management."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
def test_engine_initialization(self):
|
|
self.assertIsNotNone(self.engine.simulator)
|
|
self.assertIsNotNone(self.engine.triggers)
|
|
self.assertEqual(len(self.engine.impostor_ids), 0)
|
|
|
|
def test_add_crewmate(self):
|
|
player = self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
|
|
self.assertEqual(player.role, Role.CREWMATE)
|
|
self.assertEqual(player.position.room_id, "cafeteria")
|
|
self.assertIn("p1", self.engine.simulator.players)
|
|
self.assertNotIn("p1", self.engine.impostor_ids)
|
|
|
|
def test_add_impostor(self):
|
|
player = self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR)
|
|
|
|
self.assertEqual(player.role, Role.IMPOSTOR)
|
|
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)
|
|
self.engine.add_player("p3", "Green", "green", Role.CREWMATE)
|
|
|
|
context = self.engine.get_impostor_context("p1")
|
|
|
|
self.assertIn("fellow_impostors", context)
|
|
self.assertEqual(len(context["fellow_impostors"]), 1)
|
|
self.assertEqual(context["fellow_impostors"][0]["id"], "p2")
|
|
|
|
def test_impostor_context_for_crewmate(self):
|
|
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
context = self.engine.get_impostor_context("p1")
|
|
self.assertEqual(context, {})
|
|
|
|
|
|
class TestActionQueue(unittest.TestCase):
|
|
"""Tests for action queueing and resolution."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR)
|
|
|
|
def test_queue_action(self):
|
|
pos = self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
|
self.assertEqual(pos, 0)
|
|
self.assertEqual(len(self.engine._action_queue), 1)
|
|
|
|
def test_action_priority(self):
|
|
# Queue in wrong priority order
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
|
self.engine.queue_action("p2", "KILL", {"target_id": "p1"})
|
|
self.engine.queue_action("p2", "SABOTAGE", {"system": "lights"})
|
|
|
|
# Resolve - should process in priority order
|
|
results = self.engine.resolve_actions()
|
|
|
|
# SABOTAGE should be first, then KILL, then MOVE
|
|
self.assertEqual(results[0]["action"], "SABOTAGE")
|
|
self.assertEqual(results[1]["action"], "KILL")
|
|
self.assertEqual(results[2]["action"], "MOVE")
|
|
|
|
def test_queue_clears_after_resolve(self):
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "admin"})
|
|
self.engine.resolve_actions()
|
|
|
|
self.assertEqual(len(self.engine._action_queue), 0)
|
|
|
|
|
|
class TestMovement(unittest.TestCase):
|
|
"""Tests for player movement."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
|
|
def test_move_to_adjacent_room(self):
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_move_same_room(self):
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "cafeteria"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_move_no_path(self):
|
|
# Add isolated room
|
|
self.game_map.add_room(Room(id="isolated", name="Isolated"))
|
|
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "isolated"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_dead_player_cannot_move(self):
|
|
player = self.engine.simulator.get_player("p1")
|
|
player.is_alive = False
|
|
|
|
self.engine.queue_action("p1", "MOVE", {"destination": "electrical"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestKill(unittest.TestCase):
|
|
"""Tests for kill mechanics."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
|
|
|
# Reset kill cooldown for tests
|
|
imp = self.engine.simulator.get_player("imp")
|
|
imp.kill_cooldown = 0
|
|
|
|
def test_successful_kill(self):
|
|
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_crewmate_cannot_kill(self):
|
|
self.engine.queue_action("crew", "KILL", {"target_id": "imp"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_cannot_kill_different_room(self):
|
|
crew = self.engine.simulator.get_player("crew")
|
|
crew.position = Position(room_id="electrical")
|
|
|
|
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_kill_cooldown_blocks(self):
|
|
imp = self.engine.simulator.get_player("imp")
|
|
imp.kill_cooldown = 10.0
|
|
|
|
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_kill_creates_body(self):
|
|
self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
|
|
self.engine.resolve_actions()
|
|
|
|
# Process kill event
|
|
self.engine.simulator.run_until_empty()
|
|
|
|
self.assertEqual(len(self.engine.simulator.bodies), 1)
|
|
self.assertFalse(self.engine.simulator.get_player("crew").is_alive)
|
|
|
|
|
|
class TestVenting(unittest.TestCase):
|
|
"""Tests for vent mechanics."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
|
|
|
# Place impostor in electrical (has vent)
|
|
imp = self.engine.simulator.get_player("imp")
|
|
imp.position = Position(room_id="electrical")
|
|
|
|
def test_impostor_can_vent(self):
|
|
self.engine.queue_action("imp", "VENT", {"destination": "security"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_crewmate_cannot_vent(self):
|
|
crew = self.engine.simulator.get_player("crew")
|
|
crew.position = Position(room_id="electrical")
|
|
|
|
self.engine.queue_action("crew", "VENT", {"destination": "security"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_cannot_vent_unconnected(self):
|
|
# Cafeteria has no vent
|
|
imp = self.engine.simulator.get_player("imp")
|
|
imp.position = Position(room_id="cafeteria")
|
|
|
|
self.engine.queue_action("imp", "VENT", {"destination": "security"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestTasks(unittest.TestCase):
|
|
"""Tests for task mechanics."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
player = self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
player.tasks_assigned = ["wires_cafe", "wires_elec"]
|
|
|
|
def test_start_task_in_current_room(self):
|
|
self.engine.queue_action("p1", "TASK", {"task_id": "wires_cafe"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_cannot_do_task_in_wrong_room(self):
|
|
# wires_elec is in electrical, player is in cafeteria
|
|
self.engine.queue_action("p1", "TASK", {"task_id": "wires_elec"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_cannot_do_unassigned_task(self):
|
|
self.engine.queue_action("p1", "TASK", {"task_id": "nonexistent"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestReporting(unittest.TestCase):
|
|
"""Tests for body reporting."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
|
|
# Create a body in cafeteria
|
|
body = Body(
|
|
id="body1",
|
|
player_id="dead",
|
|
player_name="Blue",
|
|
position=Position(room_id="cafeteria"),
|
|
time_of_death=0.0
|
|
)
|
|
self.engine.simulator.bodies.append(body)
|
|
|
|
def test_report_body_in_room(self):
|
|
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_cannot_report_body_in_different_room(self):
|
|
player = self.engine.simulator.get_player("p1")
|
|
player.position = Position(room_id="electrical")
|
|
|
|
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_cannot_report_already_reported(self):
|
|
self.engine.simulator.bodies[0].reported = True
|
|
|
|
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestEmergency(unittest.TestCase):
|
|
"""Tests for emergency button."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
self.engine.add_player("p1", "Red", "red", Role.CREWMATE)
|
|
|
|
def test_call_emergency_in_cafeteria(self):
|
|
self.engine.queue_action("p1", "EMERGENCY", {})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_cannot_call_emergency_outside_cafeteria(self):
|
|
player = self.engine.simulator.get_player("p1")
|
|
player.position = Position(room_id="electrical")
|
|
|
|
self.engine.queue_action("p1", "EMERGENCY", {})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_emergency_limit_per_player(self):
|
|
# Use up emergency
|
|
self.engine.queue_action("p1", "EMERGENCY", {})
|
|
self.engine.resolve_actions()
|
|
|
|
# Try again
|
|
self.engine.queue_action("p1", "EMERGENCY", {})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestSabotage(unittest.TestCase):
|
|
"""Tests for sabotage mechanics."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
|
|
def test_impostor_can_sabotage(self):
|
|
self.engine.queue_action("imp", "SABOTAGE", {"system": "lights"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertTrue(results[0]["success"])
|
|
|
|
def test_cannot_double_sabotage(self):
|
|
self.engine.active_sabotage = "lights"
|
|
|
|
self.engine.queue_action("imp", "SABOTAGE", {"system": "o2"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
def test_invalid_sabotage_system(self):
|
|
self.engine.queue_action("imp", "SABOTAGE", {"system": "invalid"})
|
|
results = self.engine.resolve_actions()
|
|
|
|
self.assertFalse(results[0]["success"])
|
|
|
|
|
|
class TestWinConditions(unittest.TestCase):
|
|
"""Tests for win condition checking."""
|
|
|
|
def setUp(self):
|
|
self.config = GameConfig()
|
|
self.game_map = create_simple_map()
|
|
self.engine = GameEngine(self.config, self.game_map)
|
|
|
|
def test_no_win_yet(self):
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("c1", "Blue", "blue", Role.CREWMATE)
|
|
self.engine.add_player("c2", "Green", "green", Role.CREWMATE)
|
|
|
|
self.assertIsNone(self.engine.check_win_condition())
|
|
|
|
def test_impostor_wins_by_parity(self):
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
|
|
|
self.assertEqual(self.engine.check_win_condition(), "impostor")
|
|
|
|
def test_crewmate_wins_all_impostors_dead(self):
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("crew", "Blue", "blue", Role.CREWMATE)
|
|
|
|
imp = self.engine.simulator.get_player("imp")
|
|
imp.is_alive = False
|
|
|
|
self.assertEqual(self.engine.check_win_condition(), "crewmate")
|
|
|
|
def test_crewmate_wins_all_tasks(self):
|
|
self.engine.add_player("imp", "Red", "red", Role.IMPOSTOR)
|
|
self.engine.add_player("c1", "Blue", "blue", Role.CREWMATE)
|
|
self.engine.add_player("c2", "Green", "green", Role.CREWMATE)
|
|
|
|
c1 = self.engine.simulator.get_player("c1")
|
|
c2 = self.engine.simulator.get_player("c2")
|
|
|
|
c1.tasks_assigned = ["t1"]
|
|
c1.tasks_completed = ["t1"]
|
|
c2.tasks_assigned = ["t2"]
|
|
c2.tasks_completed = ["t2"]
|
|
|
|
self.assertEqual(self.engine.check_win_condition(), "crewmate")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|