mud/src/mudlib/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

155 lines
4.9 KiB
Python

"""NPC behavior state machine."""
from mudlib.entity import Mob
from mudlib.zone import Zone
# Valid behavior states
VALID_STATES = {"idle", "patrol", "converse", "flee", "working"}
def transition_state(mob: Mob, new_state: str, data: dict | None = None) -> bool:
"""Transition mob to a new behavior state.
Args:
mob: The mob to transition
new_state: The target state (must be in VALID_STATES)
data: Optional state-specific data
Returns:
True if transition succeeded, False if state is invalid
"""
if new_state not in VALID_STATES:
return False
mob.behavior_state = new_state
mob.behavior_data = data or {}
return True
async def process_behavior(mob: Mob, world: Zone) -> None:
"""Process mob behavior for current tick.
Called each game loop tick. Dispatches based on behavior_state:
- idle: do nothing (existing wander logic handles movement)
- patrol: move through waypoints
- converse: do nothing (conversation is player-driven)
- flee: do nothing (direction computed by get_flee_direction)
- working: do nothing (stationary NPC)
Args:
mob: The mob to process
world: The zone the mob is in
"""
if mob.behavior_state == "idle":
# No-op, existing wander logic handles idle movement
pass
elif mob.behavior_state == "patrol":
await _process_patrol(mob, world)
elif mob.behavior_state == "converse":
# No-op, mob stays put during conversation
pass
elif mob.behavior_state == "flee":
# No-op, mob_ai uses get_flee_direction for movement
pass
elif mob.behavior_state == "working":
# No-op, mob stays at their post
pass
async def _process_patrol(mob: Mob, world: Zone) -> None:
"""Process patrol behavior: advance waypoint if at current target."""
waypoints = mob.behavior_data.get("waypoints", [])
if not waypoints:
return
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
current_waypoint = waypoints[waypoint_index]
# Check if at waypoint (use world wrapping for distance)
if mob.x == current_waypoint["x"] and mob.y == current_waypoint["y"]:
# Advance to next waypoint (loop back to start if at end)
waypoint_index = (waypoint_index + 1) % len(waypoints)
mob.behavior_data["waypoint_index"] = waypoint_index
def get_flee_direction(mob: Mob, world: Zone) -> str | None:
"""Get the cardinal direction to flee away from threat.
Args:
mob: The mob fleeing
world: The zone the mob is in
Returns:
Cardinal direction ("north", "south", "east", "west") or None if no threat
"""
flee_from = mob.behavior_data.get("flee_from")
if not flee_from:
return None
threat_x = flee_from["x"]
threat_y = flee_from["y"]
# Calculate direction away from threat
dx = mob.x - threat_x
dy = mob.y - threat_y
# Handle toroidal wrapping for shortest distance
if world.toroidal:
# Adjust dx/dy if wrapping gives shorter distance
if abs(dx) > world.width // 2:
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
if abs(dy) > world.height // 2:
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
# Prefer axis with larger distance (prefer moving away faster)
if abs(dx) >= abs(dy):
return "east" if dx > 0 else "west" if dx < 0 else None
else:
return "south" if dy > 0 else "north" if dy < 0 else None
def get_patrol_direction(mob: Mob, world: Zone) -> str | None:
"""Get the cardinal direction to move toward current patrol waypoint.
Args:
mob: The mob on patrol
world: The zone the mob is in
Returns:
Cardinal direction ("north", "south", "east", "west") or None if at waypoint
"""
waypoints = mob.behavior_data.get("waypoints", [])
if not waypoints:
return None
waypoint_index = mob.behavior_data.get("waypoint_index", 0)
current_waypoint = waypoints[waypoint_index]
target_x = current_waypoint["x"]
target_y = current_waypoint["y"]
# Check if already at waypoint
if mob.x == target_x and mob.y == target_y:
return None
# Calculate direction to waypoint
dx = target_x - mob.x
dy = target_y - mob.y
# Handle toroidal wrapping (find shortest path)
if world.toroidal:
# Check if wrapping gives a shorter distance on x axis
if abs(dx) > world.width // 2:
# Wrapping is shorter, reverse direction
dx = -(world.width - abs(dx)) * (1 if dx > 0 else -1)
# Check if wrapping gives a shorter distance on y axis
if abs(dy) > world.height // 2:
# Wrapping is shorter, reverse direction
dy = -(world.height - abs(dy)) * (1 if dy > 0 else -1)
# Prefer axis with larger distance (ties prefer x)
if abs(dx) >= abs(dy):
return "east" if dx > 0 else "west"
else:
return "south" if dy > 0 else "north"