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:
parent
52f49104eb
commit
f0238d9e49
2 changed files with 344 additions and 35 deletions
|
|
@ -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"
|
||||
|
|
|
|||
252
tests/test_mob_ai_behavior_integration.py
Normal file
252
tests/test_mob_ai_behavior_integration.py
Normal 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
|
||||
Loading…
Reference in a new issue