mud/tests/test_mob_ai_behavior_integration.py
Jared Miller f0238d9e49
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.
2026-02-14 14:31:39 -05:00

252 lines
6.8 KiB
Python

"""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