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.
This commit is contained in:
Jared Miller 2026-02-14 13:06:47 -05:00
parent 52f49104eb
commit f0238d9e49
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 344 additions and 35 deletions

View file

@ -12,6 +12,11 @@ from mudlib.commands.movement import (
send_nearby_message, send_nearby_message,
) )
from mudlib.mobs import mobs 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.colors import colorize
from mudlib.render.pov import render_pov from mudlib.render.pov import render_pov
from mudlib.zone import Zone from mudlib.zone import Zone
@ -139,10 +144,14 @@ async def process_mob_movement() -> None:
Called once per game loop tick. For each mob: Called once per game loop tick. For each mob:
- Skip if in combat/encounter - Skip if in combat/encounter
- Skip if no home region set - Skip if behavior state blocks movement (converse, working)
- Skip if already inside home region - 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) - 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) - Move one tile in that direction (prefer axis with larger distance)
- Check passability before moving - Check passability before moving
- Broadcast movement to nearby players - Broadcast movement to nearby players
@ -158,6 +167,58 @@ async def process_mob_movement() -> None:
if encounter is not None: if encounter is not None:
continue continue
# 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
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 # Skip if no home region set
if ( if (
mob.home_x_min is None mob.home_x_min is None
@ -174,10 +235,6 @@ async def process_mob_movement() -> None:
): ):
continue continue
# Skip if not enough time has passed (throttle)
if now < mob.next_action_at:
continue
# Calculate center of home region # Calculate center of home region
center_x = (mob.home_x_min + mob.home_x_max) // 2 center_x = (mob.home_x_min + mob.home_x_max) // 2
center_y = (mob.home_y_min + mob.home_y_max) // 2 center_y = (mob.home_y_min + mob.home_y_max) // 2
@ -187,17 +244,18 @@ async def process_mob_movement() -> None:
dy = center_y - mob.y dy = center_y - mob.y
# Move one tile (prefer axis with larger distance, ties prefer x) # Move one tile (prefer axis with larger distance, ties prefer x)
move_x = 0
move_y = 0
if abs(dx) >= abs(dy): if abs(dx) >= abs(dy):
move_x = 1 if dx > 0 else -1 if dx < 0 else 0 move_x = 1 if dx > 0 else -1 if dx < 0 else 0
else: else:
move_y = 1 if dy > 0 else -1 if dy < 0 else 0 move_y = 1 if dy > 0 else -1 if dy < 0 else 0
# Check if target is passable direction_name = get_direction_name(move_x, move_y)
zone = mob.location
assert isinstance(zone, Zone), "Mob must be in a zone to move"
# 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_x = mob.x + move_x
target_y = mob.y + move_y target_y = mob.y + move_y
@ -210,7 +268,6 @@ async def process_mob_movement() -> None:
continue continue
# Send departure message # Send departure message
direction_name = get_direction_name(move_x, move_y)
if direction_name: if direction_name:
await send_nearby_message( await send_nearby_message(
mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n" mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n"

View file

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