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