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.
229 lines
8.8 KiB
Python
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
|