"""Tests for NPC behavior state machine.""" import pytest from mudlib.entity import Mob from mudlib.npc_behavior import ( VALID_STATES, get_flee_direction, get_patrol_direction, process_behavior, transition_state, ) from mudlib.zone import Zone @pytest.fixture def test_zone(): """Create a simple test zone for behavior tests.""" terrain = [["." for _ in range(256)] for _ in range(256)] zone = Zone( name="testzone", width=256, height=256, toroidal=True, terrain=terrain, impassable=set(), ) return zone @pytest.fixture def mob(test_zone): """Create a basic mob for testing.""" m = Mob(name="test mob", x=10, y=10) m.location = test_zone return m class TestTransitionState: def test_valid_transition_sets_state(self, mob): """transition_state with valid state sets behavior_state.""" result = transition_state(mob, "patrol") assert result is True assert mob.behavior_state == "patrol" def test_valid_transition_sets_data(self, mob): """transition_state sets behavior_data when provided.""" data = {"waypoints": [{"x": 5, "y": 5}]} result = transition_state(mob, "patrol", data) assert result is True assert mob.behavior_data == data def test_valid_transition_clears_data(self, mob): """transition_state clears data if None provided.""" mob.behavior_data = {"old": "data"} result = transition_state(mob, "idle", None) assert result is True assert mob.behavior_data == {} def test_invalid_state_returns_false(self, mob): """transition_state with invalid state returns False.""" result = transition_state(mob, "invalid_state") assert result is False def test_invalid_state_no_change(self, mob): """transition_state with invalid state doesn't change mob.""" original_state = mob.behavior_state original_data = mob.behavior_data transition_state(mob, "invalid_state") assert mob.behavior_state == original_state assert mob.behavior_data == original_data def test_all_valid_states_accepted(self, mob): """All states in VALID_STATES can be transitioned to.""" for state in VALID_STATES: result = transition_state(mob, state) assert result is True assert mob.behavior_state == state class TestDefaultState: def test_new_mob_starts_idle(self): """New Mob instance defaults to idle state.""" mob = Mob(name="new mob", x=0, y=0) assert mob.behavior_state == "idle" assert mob.behavior_data == {} class TestPatrolMovement: @pytest.mark.asyncio async def test_patrol_moves_toward_waypoint(self, mob, test_zone): """Mob on patrol moves toward current waypoint.""" waypoints = [{"x": 15, "y": 10}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) # process_behavior should determine direction but not move # (movement is handled by mob_ai integration later) direction = get_patrol_direction(mob, test_zone) assert direction == "east" # mob at (10, 10) moves east to (15, 10) @pytest.mark.asyncio async def test_patrol_advances_waypoint(self, mob, test_zone): """Mob reaching waypoint advances to next.""" waypoints = [{"x": 10, "y": 10}, {"x": 15, "y": 15}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) # At first waypoint already direction = get_patrol_direction(mob, test_zone) assert direction is None # at waypoint # Process behavior to advance await process_behavior(mob, test_zone) assert mob.behavior_data["waypoint_index"] == 1 @pytest.mark.asyncio async def test_patrol_loops_waypoints(self, mob, test_zone): """Patrol loops back to first waypoint after last.""" waypoints = [{"x": 5, "y": 5}, {"x": 10, "y": 10}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 1}) # At second waypoint (last one) direction = get_patrol_direction(mob, test_zone) assert direction is None await process_behavior(mob, test_zone) assert mob.behavior_data["waypoint_index"] == 0 # looped back def test_patrol_direction_north(self, mob, test_zone): """get_patrol_direction returns 'north' when waypoint is north.""" waypoints = [{"x": 10, "y": 5}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) direction = get_patrol_direction(mob, test_zone) assert direction == "north" def test_patrol_direction_south(self, mob, test_zone): """get_patrol_direction returns 'south' when waypoint is south.""" waypoints = [{"x": 10, "y": 15}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) direction = get_patrol_direction(mob, test_zone) assert direction == "south" def test_patrol_direction_west(self, mob, test_zone): """get_patrol_direction returns 'west' when waypoint is west.""" waypoints = [{"x": 5, "y": 10}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) direction = get_patrol_direction(mob, test_zone) assert direction == "west" def test_patrol_direction_with_wrapping(self, mob, test_zone): """get_patrol_direction handles toroidal wrapping.""" # Mob at (10, 10), waypoint at (250, 10) # Direct path: 240 tiles east # Wrapped path: 20 tiles west (256 - 240 + 10) waypoints = [{"x": 250, "y": 10}] transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) direction = get_patrol_direction(mob, test_zone) assert direction == "west" # wrapping is shorter class TestConverseState: @pytest.mark.asyncio async def test_converse_state_no_movement(self, mob, test_zone): """Mob in converse state doesn't move.""" original_x, original_y = mob.x, mob.y transition_state(mob, "converse") await process_behavior(mob, test_zone) assert mob.x == original_x assert mob.y == original_y class TestFleeState: def test_flee_direction_west(self, mob, test_zone): """get_flee_direction returns 'west' when fleeing from threat to the east.""" # Threat at (15, 10), mob at (10, 10) # Should flee west (away from threat) transition_state(mob, "flee", {"flee_from": {"x": 15, "y": 10}}) direction = get_flee_direction(mob, test_zone) assert direction == "west" def test_flee_direction_east(self, mob, test_zone): """get_flee_direction returns 'east' when fleeing from threat to the west.""" # Threat at (5, 10), mob at (10, 10) # Should flee east (away from threat) transition_state(mob, "flee", {"flee_from": {"x": 5, "y": 10}}) direction = get_flee_direction(mob, test_zone) assert direction == "east" def test_flee_direction_north(self, mob, test_zone): """get_flee_direction returns 'north' when fleeing from threat to the south.""" # Threat at (10, 15), mob at (10, 10) # Should flee north (away from threat) transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 15}}) direction = get_flee_direction(mob, test_zone) assert direction == "north" def test_flee_direction_south(self, mob, test_zone): """get_flee_direction returns 'south' when fleeing from threat to the north.""" # Threat at (10, 5), mob at (10, 10) # Should flee south (away from threat) transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 5}}) direction = get_flee_direction(mob, test_zone) assert direction == "south" def test_flee_no_threat(self, mob, test_zone): """get_flee_direction returns None when no threat specified.""" transition_state(mob, "flee", {}) direction = get_flee_direction(mob, test_zone) assert direction is None class TestWorkingState: @pytest.mark.asyncio async def test_working_state_no_movement(self, mob, test_zone): """Mob in working state stays put.""" original_x, original_y = mob.x, mob.y transition_state(mob, "working") await process_behavior(mob, test_zone) assert mob.x == original_x assert mob.y == original_y class TestIdleState: @pytest.mark.asyncio async def test_idle_state_no_action(self, mob, test_zone): """Idle state does nothing (movement handled by mob_ai).""" original_x, original_y = mob.x, mob.y transition_state(mob, "idle") await process_behavior(mob, test_zone) # process_behavior is a no-op for idle assert mob.x == original_x assert mob.y == original_y