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