From f0238d9e4951b19c2aa891f05e90cab59d71026c Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 13:06:47 -0500 Subject: [PATCH] Integrate behavior states into mob movement Mob movement now respects NPC behavior states: - converse and working states block movement (NPCs stay put) - patrol state uses waypoint navigation instead of home region - flee state moves away from threat coordinates - idle state uses original home region wander logic Tests verify each behavior state influences movement correctly. --- src/mudlib/mob_ai.py | 127 ++++++++--- tests/test_mob_ai_behavior_integration.py | 252 ++++++++++++++++++++++ 2 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 tests/test_mob_ai_behavior_integration.py diff --git a/src/mudlib/mob_ai.py b/src/mudlib/mob_ai.py index 490459a..5bb5579 100644 --- a/src/mudlib/mob_ai.py +++ b/src/mudlib/mob_ai.py @@ -12,6 +12,11 @@ from mudlib.commands.movement import ( send_nearby_message, ) from mudlib.mobs import mobs +from mudlib.npc_behavior import ( + get_flee_direction, + get_patrol_direction, + process_behavior, +) from mudlib.render.colors import colorize from mudlib.render.pov import render_pov from mudlib.zone import Zone @@ -139,10 +144,14 @@ async def process_mob_movement() -> None: Called once per game loop tick. For each mob: - Skip if in combat/encounter - - Skip if no home region set - - Skip if already inside home region + - Skip if behavior state blocks movement (converse, working) + - If patrol state, use waypoint navigation instead of home region + - If flee state, move away from threat + - If idle state (default), use home region wander logic + - Skip if no home region set (for idle state) + - Skip if already inside home region (for idle state) - Skip if not enough time has passed since last move (throttle) - - Calculate direction toward home region center + - Calculate direction (behavior-dependent or toward home region center) - Move one tile in that direction (prefer axis with larger distance) - Check passability before moving - Broadcast movement to nearby players @@ -158,46 +167,95 @@ async def process_mob_movement() -> None: if encounter is not None: continue - # Skip if no home region set - if ( - mob.home_x_min is None - or mob.home_x_max is None - or mob.home_y_min is None - or mob.home_y_max is None - ): - continue - - # Skip if already inside home region - if ( - mob.home_x_min <= mob.x <= mob.home_x_max - and mob.home_y_min <= mob.y <= mob.home_y_max - ): + # Skip if behavior state blocks movement + if mob.behavior_state in ("converse", "working"): continue # Skip if not enough time has passed (throttle) if now < mob.next_action_at: continue - # Calculate center of home region - center_x = (mob.home_x_min + mob.home_x_max) // 2 - center_y = (mob.home_y_min + mob.home_y_max) // 2 - - # Calculate direction toward center - dx = center_x - mob.x - dy = center_y - mob.y - - # Move one tile (prefer axis with larger distance, ties prefer x) - move_x = 0 - move_y = 0 - if abs(dx) >= abs(dy): - move_x = 1 if dx > 0 else -1 if dx < 0 else 0 - else: - move_y = 1 if dy > 0 else -1 if dy < 0 else 0 - - # Check if target is passable zone = mob.location assert isinstance(zone, Zone), "Mob must be in a zone to move" + # Determine movement direction based on behavior state + move_x = 0 + move_y = 0 + direction_name = None + + if mob.behavior_state == "patrol": + # Use patrol waypoint navigation + direction_name = get_patrol_direction(mob, zone) + if direction_name is None: + # Already at waypoint, process behavior to advance waypoint + await process_behavior(mob, zone) + continue + + # Convert direction name to movement delta + if direction_name == "north": + move_y = -1 + elif direction_name == "south": + move_y = 1 + elif direction_name == "east": + move_x = 1 + elif direction_name == "west": + move_x = -1 + + elif mob.behavior_state == "flee": + # Use flee direction calculation + direction_name = get_flee_direction(mob, zone) + if direction_name is None: + continue + + # Convert direction name to movement delta + if direction_name == "north": + move_y = -1 + elif direction_name == "south": + move_y = 1 + elif direction_name == "east": + move_x = 1 + elif direction_name == "west": + move_x = -1 + + else: + # Default behavior (idle): move toward home region + # Skip if no home region set + if ( + mob.home_x_min is None + or mob.home_x_max is None + or mob.home_y_min is None + or mob.home_y_max is None + ): + continue + + # Skip if already inside home region + if ( + mob.home_x_min <= mob.x <= mob.home_x_max + and mob.home_y_min <= mob.y <= mob.home_y_max + ): + continue + + # Calculate center of home region + center_x = (mob.home_x_min + mob.home_x_max) // 2 + center_y = (mob.home_y_min + mob.home_y_max) // 2 + + # Calculate direction toward center + dx = center_x - mob.x + dy = center_y - mob.y + + # Move one tile (prefer axis with larger distance, ties prefer x) + if abs(dx) >= abs(dy): + move_x = 1 if dx > 0 else -1 if dx < 0 else 0 + else: + move_y = 1 if dy > 0 else -1 if dy < 0 else 0 + + direction_name = get_direction_name(move_x, move_y) + + # Check if we have a valid movement + if move_x == 0 and move_y == 0: + continue + + # Calculate target position target_x = mob.x + move_x target_y = mob.y + move_y @@ -210,7 +268,6 @@ async def process_mob_movement() -> None: continue # Send departure message - direction_name = get_direction_name(move_x, move_y) if direction_name: await send_nearby_message( mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n" diff --git a/tests/test_mob_ai_behavior_integration.py b/tests/test_mob_ai_behavior_integration.py new file mode 100644 index 0000000..427a12d --- /dev/null +++ b/tests/test_mob_ai_behavior_integration.py @@ -0,0 +1,252 @@ +"""Tests for mob AI integration with behavior states.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.combat.commands import combat_moves +from mudlib.combat.engine import active_encounters +from mudlib.combat.moves import load_moves +from mudlib.entity import Mob +from mudlib.mob_ai import process_mob_movement +from mudlib.mobs import mobs +from mudlib.npc_behavior import transition_state +from mudlib.player import Player, players +from mudlib.zone import Zone + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear mobs, encounters, and players before and after each test.""" + mobs.clear() + active_encounters.clear() + players.clear() + yield + mobs.clear() + active_encounters.clear() + players.clear() + + +@pytest.fixture +def test_zone(): + """Create a test zone for entities.""" + 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 mock_writer(): + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +@pytest.fixture +def mock_reader(): + return MagicMock() + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): + p = Player(name="Hero", x=0, y=0, reader=mock_reader, writer=mock_writer) + p.location = test_zone + test_zone._contents.append(p) + players[p.name] = p + return p + + +@pytest.fixture +def moves(): + """Load combat moves from content directory.""" + content_dir = Path(__file__).parent.parent / "content" / "combat" + return load_moves(content_dir) + + +@pytest.fixture(autouse=True) +def inject_moves(moves): + """Inject loaded moves into combat commands module.""" + combat_moves.update(moves) + yield + combat_moves.clear() + + +class TestConverseBehaviorBlocksMovement: + @pytest.mark.asyncio + async def test_converse_state_prevents_wander(self, test_zone): + """Mob in converse state doesn't wander (stays put).""" + mob = Mob( + name="librarian", + x=10, + y=10, + location=test_zone, + home_x_min=0, + home_x_max=5, + home_y_min=0, + home_y_max=5, + ) + mobs.append(mob) + mob.next_action_at = 0.0 # cooldown expired + + # Transition to converse state + transition_state(mob, "converse") + + # Process movement + await process_mob_movement() + + # Mob should not have moved + assert mob.x == 10 + assert mob.y == 10 + + +class TestWorkingBehaviorBlocksMovement: + @pytest.mark.asyncio + async def test_working_state_prevents_wander(self, test_zone): + """Mob in working state doesn't wander (stays at post).""" + mob = Mob( + name="blacksmith", + x=20, + y=20, + location=test_zone, + home_x_min=0, + home_x_max=5, + home_y_min=0, + home_y_max=5, + ) + mobs.append(mob) + mob.next_action_at = 0.0 # cooldown expired + + # Transition to working state + transition_state(mob, "working") + + # Process movement + await process_mob_movement() + + # Mob should not have moved + assert mob.x == 20 + assert mob.y == 20 + + +class TestPatrolBehaviorUsesWaypoints: + @pytest.mark.asyncio + async def test_patrol_moves_toward_waypoint_not_home(self, test_zone): + """Mob in patrol state moves toward waypoint, not home region.""" + mob = Mob( + name="guard", + x=10, + y=10, + location=test_zone, + home_x_min=0, + home_x_max=5, + home_y_min=0, + home_y_max=5, + ) + mobs.append(mob) + mob.next_action_at = 0.0 + + # Set patrol with waypoint to the east + waypoints = [{"x": 20, "y": 10}] + transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) + + # Process movement + await process_mob_movement() + + # Mob should have moved east (toward waypoint at 20,10) + # NOT west toward home region (0-5, 0-5) + assert mob.x == 11 + assert mob.y == 10 + + @pytest.mark.asyncio + async def test_patrol_advances_waypoint_on_arrival(self, test_zone): + """Mob in patrol state advances waypoint when reaching it.""" + waypoints = [{"x": 10, "y": 10}, {"x": 20, "y": 20}] + mob = Mob( + name="guard", + x=10, + y=10, + location=test_zone, + home_x_min=0, + home_x_max=5, + home_y_min=0, + home_y_max=5, + ) + mobs.append(mob) + mob.next_action_at = 0.0 + transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0}) + + # At first waypoint already + await process_mob_movement() + + # Waypoint should advance (handled by process_behavior in npc_behavior) + # But movement is skipped since we're at the waypoint + # Mob advances waypoint, then on next tick moves toward new waypoint + assert mob.behavior_data["waypoint_index"] == 1 + + +class TestFleeBehavior: + @pytest.mark.asyncio + async def test_flee_moves_away_from_threat(self, test_zone): + """Mob in flee state moves away from threat.""" + mob = Mob( + name="rabbit", + x=10, + y=10, + location=test_zone, + home_x_min=8, + home_x_max=12, + home_y_min=8, + home_y_max=12, + ) + mobs.append(mob) + mob.next_action_at = 0.0 + + # Threat to the east + flee_data = {"flee_from": {"x": 15, "y": 10}} + transition_state(mob, "flee", flee_data) + + # Process movement + await process_mob_movement() + + # Mob should have moved west (away from threat) + assert mob.x == 9 + assert mob.y == 10 + + +class TestIdleBehaviorUsesHomeRegion: + @pytest.mark.asyncio + async def test_idle_uses_original_wander_logic(self, test_zone): + """Mob in idle state uses original home-region wander logic.""" + mob = Mob( + name="wanderer", + x=50, + y=50, + location=test_zone, + home_x_min=0, + home_x_max=5, + home_y_min=0, + home_y_max=5, + ) + mobs.append(mob) + mob.next_action_at = 0.0 + + # Explicitly set idle state + transition_state(mob, "idle") + + original_x = mob.x + original_y = mob.y + + # Process movement + await process_mob_movement() + + # Mob should have moved toward home region (toward 0-5, 0-5) + # Since mob is at (50, 50) and home is (0-5, 0-5), should move west or north + assert mob.x < original_x or mob.y < original_y