diff --git a/src/mudlib/mob_ai.py b/src/mudlib/mob_ai.py index dfe51f8..490459a 100644 --- a/src/mudlib/mob_ai.py +++ b/src/mudlib/mob_ai.py @@ -6,13 +6,22 @@ import time from mudlib.combat.encounter import CombatState from mudlib.combat.engine import get_encounter from mudlib.combat.moves import CombatMove +from mudlib.commands.movement import ( + OPPOSITE_DIRECTIONS, + get_direction_name, + send_nearby_message, +) from mudlib.mobs import mobs from mudlib.render.colors import colorize from mudlib.render.pov import render_pov +from mudlib.zone import Zone # Seconds between mob actions (gives player time to read and react) MOB_ACTION_COOLDOWN = 1.0 +# Seconds between mob pathfinding movements +MOB_MOVEMENT_COOLDOWN = 3.0 + async def process_mobs(combat_moves: dict[str, CombatMove]) -> None: """Called once per game loop tick. Handles mob combat decisions.""" @@ -123,3 +132,104 @@ def _try_defend(mob, encounter, combat_moves, now): encounter.defend(chosen) mob.stamina -= chosen.stamina_cost + + +async def process_mob_movement() -> None: + """Move mobs back toward their home regions if they've strayed. + + 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 not enough time has passed since last move (throttle) + - Calculate direction toward home region center + - Move one tile in that direction (prefer axis with larger distance) + - Check passability before moving + - Broadcast movement to nearby players + """ + now = time.monotonic() + + for mob in mobs[:]: # copy list in case of modification + if not mob.alive: + continue + + # Skip if mob is in combat + encounter = get_encounter(mob) + 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 + ): + 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" + + target_x = mob.x + move_x + target_y = mob.y + move_y + + # Handle toroidal wrapping if needed + if zone.toroidal: + target_x, target_y = zone.wrap(target_x, target_y) + + if not zone.is_passable(target_x, target_y): + # Can't move that way, skip this tick + 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" + ) + + # Update position + mob.x = target_x + mob.y = target_y + + # Send arrival message (use opposite direction) + if direction_name: + opposite = OPPOSITE_DIRECTIONS.get(direction_name, "") + if opposite: + await send_nearby_message( + mob, + mob.x, + mob.y, + f"{mob.name} wanders in from the {opposite}.\r\n", + ) + + # Set next move time + mob.next_action_at = now + MOB_MOVEMENT_COOLDOWN diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 3b16fb7..f548f1a 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -45,7 +45,7 @@ from mudlib.gmcp import ( send_room_info, ) from mudlib.if_session import broadcast_to_spectators -from mudlib.mob_ai import process_mobs +from mudlib.mob_ai import process_mob_movement, process_mobs from mudlib.mobs import load_mob_templates, mob_templates from mudlib.player import Player, players from mudlib.prompt import render_prompt @@ -101,6 +101,7 @@ async def game_loop() -> None: clear_expired() await process_combat() await process_mobs(mudlib.combat.commands.combat_moves) + await process_mob_movement() await process_resting() await process_unconscious() await process_decomposing() diff --git a/tests/test_mob_pathfinding.py b/tests/test_mob_pathfinding.py new file mode 100644 index 0000000..8bfee19 --- /dev/null +++ b/tests/test_mob_pathfinding.py @@ -0,0 +1,259 @@ +"""Tests for mob pathfinding back to home region.""" + +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.entity import Mob +from mudlib.mob_ai import process_mob_movement +from mudlib.mobs import mobs +from mudlib.zone import Zone + + +@pytest.fixture(autouse=True) +def clear_mobs(): + """Clear global mobs list before each test.""" + mobs.clear() + yield + mobs.clear() + + +@pytest.fixture +def zone(): + """Create a simple test zone.""" + terrain = [ + [".", ".", ".", ".", "."], + [".", ".", "#", ".", "."], + [".", ".", ".", ".", "."], + [".", ".", "#", ".", "."], + [".", ".", ".", ".", "."], + ] + return Zone( + name="test", + description="Test Zone", + width=5, + height=5, + terrain=terrain, + toroidal=False, + impassable={"#"}, + ) + + +@pytest.mark.asyncio +async def test_mob_outside_home_region_moves_toward_it(zone): + """Mob outside home region should move one tile toward center.""" + # Create mob at (4, 4) with home region center at (1, 1) + mob = Mob( + name="test_mob", + location=zone, + x=4, + y=4, + home_x_min=0, + home_x_max=2, + home_y_min=0, + home_y_max=2, + ) + mobs.append(mob) + + # First movement should happen immediately + await process_mob_movement() + + # Mob should move toward home (prefer larger distance axis) + # Distance to center (1,1): dx=3, dy=3 — equal, so prefer x-axis + assert mob.x == 3 and mob.y == 4 + + +@pytest.mark.asyncio +async def test_mob_inside_home_region_does_not_move(zone): + """Mob already inside home region should not move.""" + mob = Mob( + name="test_mob", + location=zone, + x=1, + y=1, + home_x_min=0, + home_x_max=2, + home_y_min=0, + home_y_max=2, + ) + mobs.append(mob) + + await process_mob_movement() + + # Position should not change + assert mob.x == 1 and mob.y == 1 + + +@pytest.mark.asyncio +async def test_mob_with_no_home_region_does_not_move(zone): + """Mob with no home region should not move.""" + mob = Mob( + name="test_mob", + location=zone, + x=2, + y=2, + ) + mobs.append(mob) + + await process_mob_movement() + + # Position should not change + assert mob.x == 2 and mob.y == 2 + + +@pytest.mark.asyncio +async def test_mob_in_combat_does_not_move(zone): + """Mob in an encounter should not move.""" + from mudlib.combat.encounter import CombatEncounter + + mob = Mob( + name="test_mob", + location=zone, + x=4, + y=4, + home_x_min=0, + home_x_max=2, + home_y_min=0, + home_y_max=2, + ) + mobs.append(mob) + + # Create a mock player for the encounter + from mudlib.player import Player + + mock_writer = MagicMock() + mock_writer.is_closing.return_value = False + mock_writer.drain = AsyncMock() + + player = Player( + name="test_player", + location=zone, + x=4, + y=4, + writer=mock_writer, + reader=MagicMock(), + ) + + # Create encounter + from mudlib.combat.engine import active_encounters + + encounter = CombatEncounter(attacker=mob, defender=player) + active_encounters.append(encounter) + + try: + await process_mob_movement() + + # Mob should not move + assert mob.x == 4 and mob.y == 4 + finally: + active_encounters.clear() + + +@pytest.mark.asyncio +async def test_movement_is_throttled(zone): + """Mob should not move on every tick (throttle ~3 seconds).""" + mob = Mob( + name="test_mob", + location=zone, + x=4, + y=4, + home_x_min=0, + home_x_max=2, + home_y_min=0, + home_y_max=2, + ) + mobs.append(mob) + + # First move should happen + await process_mob_movement() + first_x, first_y = mob.x, mob.y + assert (first_x, first_y) != (4, 4) # Moved + + # Immediate second call should not move + await process_mob_movement() + assert mob.x == first_x and mob.y == first_y + + # After waiting 3 seconds, should move again + time.sleep(3.1) + await process_mob_movement() + assert mob.x != first_x or mob.y != first_y + + +@pytest.mark.asyncio +async def test_mob_respects_impassable_terrain(zone): + """Mob should not move into impassable terrain.""" + # Position mob at (1, 0) with home at (3, 0) + # There's a wall at (2, 1) but path around it + mob = Mob( + name="test_mob", + location=zone, + x=1, + y=0, + home_x_min=3, + home_x_max=4, + home_y_min=0, + home_y_max=1, + ) + mobs.append(mob) + + await process_mob_movement() + + # Mob should move toward home (east) + assert mob.x == 2 and mob.y == 0 + + # Try to move south from (2,0) — wall at (2,1) + mob.x = 2 + mob.y = 0 + mob.home_x_min = 2 + mob.home_x_max = 2 + mob.home_y_min = 2 + mob.home_y_max = 4 + time.sleep(3.1) # Wait for throttle + + await process_mob_movement() + + # Should not have moved into wall (2,1 is '#') + # Should stay at (2, 0) since target (2,1) is blocked + assert mob.x == 2 and mob.y == 0 + + +@pytest.mark.asyncio +async def test_movement_broadcasts_to_nearby_players(zone): + """Mob movement should broadcast to nearby players.""" + from mudlib.player import Player + + mob = Mob( + name="test_mob", + location=zone, + x=2, + y=2, + home_x_min=0, + home_x_max=1, + home_y_min=0, + home_y_max=1, + ) + mobs.append(mob) + + # Create a player nearby + mock_writer = MagicMock() + mock_writer.is_closing.return_value = False + mock_writer.drain = AsyncMock() + + player = Player( + name="test_player", + location=zone, + x=2, + y=2, + writer=mock_writer, + reader=MagicMock(), + ) + + await process_mob_movement() + + # Player should receive a movement message + assert player.writer.write.called + # Check that message contains mob name and direction + call_args = [call[0][0] for call in player.writer.write.call_args_list] + movement_msg = "".join(call_args) + assert "test_mob" in movement_msg