mud/tests/test_npc_behavior.py
Jared Miller 369bc5efcb
Add NPC behavior state machine with tests
Adds behavior state tracking to Mob entity with five states: idle, patrol,
converse, flee, and working. Each state has specific processing logic:
- idle: no-op (existing wander logic handles movement)
- patrol: cycles through waypoints with toroidal wrapping support
- converse: stationary during player-driven dialogue
- flee: moves away from threat coordinates
- working: stationary NPC at their post

The behavior module is self-contained and testable, ready for integration
with mob_ai.py in a later step.
2026-02-14 14:31:39 -05:00

229 lines
8.8 KiB
Python

"""Tests for NPC behavior state machine."""
import pytest
from mudlib.entity import Mob
from mudlib.npc_behavior import (
VALID_STATES,
get_flee_direction,
get_patrol_direction,
process_behavior,
transition_state,
)
from mudlib.zone import Zone
@pytest.fixture
def test_zone():
"""Create a simple test zone for behavior tests."""
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 mob(test_zone):
"""Create a basic mob for testing."""
m = Mob(name="test mob", x=10, y=10)
m.location = test_zone
return m
class TestTransitionState:
def test_valid_transition_sets_state(self, mob):
"""transition_state with valid state sets behavior_state."""
result = transition_state(mob, "patrol")
assert result is True
assert mob.behavior_state == "patrol"
def test_valid_transition_sets_data(self, mob):
"""transition_state sets behavior_data when provided."""
data = {"waypoints": [{"x": 5, "y": 5}]}
result = transition_state(mob, "patrol", data)
assert result is True
assert mob.behavior_data == data
def test_valid_transition_clears_data(self, mob):
"""transition_state clears data if None provided."""
mob.behavior_data = {"old": "data"}
result = transition_state(mob, "idle", None)
assert result is True
assert mob.behavior_data == {}
def test_invalid_state_returns_false(self, mob):
"""transition_state with invalid state returns False."""
result = transition_state(mob, "invalid_state")
assert result is False
def test_invalid_state_no_change(self, mob):
"""transition_state with invalid state doesn't change mob."""
original_state = mob.behavior_state
original_data = mob.behavior_data
transition_state(mob, "invalid_state")
assert mob.behavior_state == original_state
assert mob.behavior_data == original_data
def test_all_valid_states_accepted(self, mob):
"""All states in VALID_STATES can be transitioned to."""
for state in VALID_STATES:
result = transition_state(mob, state)
assert result is True
assert mob.behavior_state == state
class TestDefaultState:
def test_new_mob_starts_idle(self):
"""New Mob instance defaults to idle state."""
mob = Mob(name="new mob", x=0, y=0)
assert mob.behavior_state == "idle"
assert mob.behavior_data == {}
class TestPatrolMovement:
@pytest.mark.asyncio
async def test_patrol_moves_toward_waypoint(self, mob, test_zone):
"""Mob on patrol moves toward current waypoint."""
waypoints = [{"x": 15, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# process_behavior should determine direction but not move
# (movement is handled by mob_ai integration later)
direction = get_patrol_direction(mob, test_zone)
assert direction == "east" # mob at (10, 10) moves east to (15, 10)
@pytest.mark.asyncio
async def test_patrol_advances_waypoint(self, mob, test_zone):
"""Mob reaching waypoint advances to next."""
waypoints = [{"x": 10, "y": 10}, {"x": 15, "y": 15}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
# At first waypoint already
direction = get_patrol_direction(mob, test_zone)
assert direction is None # at waypoint
# Process behavior to advance
await process_behavior(mob, test_zone)
assert mob.behavior_data["waypoint_index"] == 1
@pytest.mark.asyncio
async def test_patrol_loops_waypoints(self, mob, test_zone):
"""Patrol loops back to first waypoint after last."""
waypoints = [{"x": 5, "y": 5}, {"x": 10, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 1})
# At second waypoint (last one)
direction = get_patrol_direction(mob, test_zone)
assert direction is None
await process_behavior(mob, test_zone)
assert mob.behavior_data["waypoint_index"] == 0 # looped back
def test_patrol_direction_north(self, mob, test_zone):
"""get_patrol_direction returns 'north' when waypoint is north."""
waypoints = [{"x": 10, "y": 5}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "north"
def test_patrol_direction_south(self, mob, test_zone):
"""get_patrol_direction returns 'south' when waypoint is south."""
waypoints = [{"x": 10, "y": 15}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "south"
def test_patrol_direction_west(self, mob, test_zone):
"""get_patrol_direction returns 'west' when waypoint is west."""
waypoints = [{"x": 5, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "west"
def test_patrol_direction_with_wrapping(self, mob, test_zone):
"""get_patrol_direction handles toroidal wrapping."""
# Mob at (10, 10), waypoint at (250, 10)
# Direct path: 240 tiles east
# Wrapped path: 20 tiles west (256 - 240 + 10)
waypoints = [{"x": 250, "y": 10}]
transition_state(mob, "patrol", {"waypoints": waypoints, "waypoint_index": 0})
direction = get_patrol_direction(mob, test_zone)
assert direction == "west" # wrapping is shorter
class TestConverseState:
@pytest.mark.asyncio
async def test_converse_state_no_movement(self, mob, test_zone):
"""Mob in converse state doesn't move."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "converse")
await process_behavior(mob, test_zone)
assert mob.x == original_x
assert mob.y == original_y
class TestFleeState:
def test_flee_direction_west(self, mob, test_zone):
"""get_flee_direction returns 'west' when fleeing from threat to the east."""
# Threat at (15, 10), mob at (10, 10)
# Should flee west (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 15, "y": 10}})
direction = get_flee_direction(mob, test_zone)
assert direction == "west"
def test_flee_direction_east(self, mob, test_zone):
"""get_flee_direction returns 'east' when fleeing from threat to the west."""
# Threat at (5, 10), mob at (10, 10)
# Should flee east (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 5, "y": 10}})
direction = get_flee_direction(mob, test_zone)
assert direction == "east"
def test_flee_direction_north(self, mob, test_zone):
"""get_flee_direction returns 'north' when fleeing from threat to the south."""
# Threat at (10, 15), mob at (10, 10)
# Should flee north (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 15}})
direction = get_flee_direction(mob, test_zone)
assert direction == "north"
def test_flee_direction_south(self, mob, test_zone):
"""get_flee_direction returns 'south' when fleeing from threat to the north."""
# Threat at (10, 5), mob at (10, 10)
# Should flee south (away from threat)
transition_state(mob, "flee", {"flee_from": {"x": 10, "y": 5}})
direction = get_flee_direction(mob, test_zone)
assert direction == "south"
def test_flee_no_threat(self, mob, test_zone):
"""get_flee_direction returns None when no threat specified."""
transition_state(mob, "flee", {})
direction = get_flee_direction(mob, test_zone)
assert direction is None
class TestWorkingState:
@pytest.mark.asyncio
async def test_working_state_no_movement(self, mob, test_zone):
"""Mob in working state stays put."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "working")
await process_behavior(mob, test_zone)
assert mob.x == original_x
assert mob.y == original_y
class TestIdleState:
@pytest.mark.asyncio
async def test_idle_state_no_action(self, mob, test_zone):
"""Idle state does nothing (movement handled by mob_ai)."""
original_x, original_y = mob.x, mob.y
transition_state(mob, "idle")
await process_behavior(mob, test_zone)
# process_behavior is a no-op for idle
assert mob.x == original_x
assert mob.y == original_y