Add mob pathfinding back to home region

Mobs with home regions now pathfind back when they've strayed. Each tick,
process_mob_movement() checks all mobs and moves them one tile toward their
home region center using Manhattan distance. Movement is throttled to 3
seconds, respects impassable terrain, skips mobs in combat, and broadcasts
to nearby players.
This commit is contained in:
Jared Miller 2026-02-14 12:30:05 -05:00
parent e6ca4dc6b1
commit 75da6871ba
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 371 additions and 1 deletions

View file

@ -6,13 +6,22 @@ import time
from mudlib.combat.encounter import CombatState from mudlib.combat.encounter import CombatState
from mudlib.combat.engine import get_encounter from mudlib.combat.engine import get_encounter
from mudlib.combat.moves import CombatMove from mudlib.combat.moves import CombatMove
from mudlib.commands.movement import (
OPPOSITE_DIRECTIONS,
get_direction_name,
send_nearby_message,
)
from mudlib.mobs import mobs from mudlib.mobs import mobs
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
# Seconds between mob actions (gives player time to read and react) # Seconds between mob actions (gives player time to read and react)
MOB_ACTION_COOLDOWN = 1.0 MOB_ACTION_COOLDOWN = 1.0
# Seconds between mob pathfinding movements
MOB_MOVEMENT_COOLDOWN = 3.0
async def process_mobs(combat_moves: dict[str, CombatMove]) -> None: async def process_mobs(combat_moves: dict[str, CombatMove]) -> None:
"""Called once per game loop tick. Handles mob combat decisions.""" """Called once per game loop tick. Handles mob combat decisions."""
@ -123,3 +132,104 @@ def _try_defend(mob, encounter, combat_moves, now):
encounter.defend(chosen) encounter.defend(chosen)
mob.stamina -= chosen.stamina_cost mob.stamina -= chosen.stamina_cost
async def process_mob_movement() -> None:
"""Move mobs back toward their home regions if they've strayed.
Called once per game loop tick. For each mob:
- Skip if in combat/encounter
- Skip if no home region set
- Skip if already inside home region
- Skip if not enough time has passed since last move (throttle)
- Calculate direction toward home region center
- Move one tile in that direction (prefer axis with larger distance)
- Check passability before moving
- Broadcast movement to nearby players
"""
now = time.monotonic()
for mob in mobs[:]: # copy list in case of modification
if not mob.alive:
continue
# Skip if mob is in combat
encounter = get_encounter(mob)
if encounter is not None:
continue
# Skip if no home region set
if (
mob.home_x_min is None
or mob.home_x_max is None
or mob.home_y_min is None
or mob.home_y_max is None
):
continue
# Skip if already inside home region
if (
mob.home_x_min <= mob.x <= mob.home_x_max
and mob.home_y_min <= mob.y <= mob.home_y_max
):
continue
# Skip if not enough time has passed (throttle)
if now < mob.next_action_at:
continue
# Calculate center of home region
center_x = (mob.home_x_min + mob.home_x_max) // 2
center_y = (mob.home_y_min + mob.home_y_max) // 2
# Calculate direction toward center
dx = center_x - mob.x
dy = center_y - mob.y
# Move one tile (prefer axis with larger distance, ties prefer x)
move_x = 0
move_y = 0
if abs(dx) >= abs(dy):
move_x = 1 if dx > 0 else -1 if dx < 0 else 0
else:
move_y = 1 if dy > 0 else -1 if dy < 0 else 0
# Check if target is passable
zone = mob.location
assert isinstance(zone, Zone), "Mob must be in a zone to move"
target_x = mob.x + move_x
target_y = mob.y + move_y
# Handle toroidal wrapping if needed
if zone.toroidal:
target_x, target_y = zone.wrap(target_x, target_y)
if not zone.is_passable(target_x, target_y):
# Can't move that way, skip this tick
continue
# Send departure message
direction_name = get_direction_name(move_x, move_y)
if direction_name:
await send_nearby_message(
mob, mob.x, mob.y, f"{mob.name} wanders {direction_name}.\r\n"
)
# Update position
mob.x = target_x
mob.y = target_y
# Send arrival message (use opposite direction)
if direction_name:
opposite = OPPOSITE_DIRECTIONS.get(direction_name, "")
if opposite:
await send_nearby_message(
mob,
mob.x,
mob.y,
f"{mob.name} wanders in from the {opposite}.\r\n",
)
# Set next move time
mob.next_action_at = now + MOB_MOVEMENT_COOLDOWN

View file

@ -45,7 +45,7 @@ from mudlib.gmcp import (
send_room_info, send_room_info,
) )
from mudlib.if_session import broadcast_to_spectators from mudlib.if_session import broadcast_to_spectators
from mudlib.mob_ai import process_mobs from mudlib.mob_ai import process_mob_movement, process_mobs
from mudlib.mobs import load_mob_templates, mob_templates from mudlib.mobs import load_mob_templates, mob_templates
from mudlib.player import Player, players from mudlib.player import Player, players
from mudlib.prompt import render_prompt from mudlib.prompt import render_prompt
@ -101,6 +101,7 @@ async def game_loop() -> None:
clear_expired() clear_expired()
await process_combat() await process_combat()
await process_mobs(mudlib.combat.commands.combat_moves) await process_mobs(mudlib.combat.commands.combat_moves)
await process_mob_movement()
await process_resting() await process_resting()
await process_unconscious() await process_unconscious()
await process_decomposing() await process_decomposing()

View file

@ -0,0 +1,259 @@
"""Tests for mob pathfinding back to home region."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.entity import Mob
from mudlib.mob_ai import process_mob_movement
from mudlib.mobs import mobs
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_mobs():
"""Clear global mobs list before each test."""
mobs.clear()
yield
mobs.clear()
@pytest.fixture
def zone():
"""Create a simple test zone."""
terrain = [
[".", ".", ".", ".", "."],
[".", ".", "#", ".", "."],
[".", ".", ".", ".", "."],
[".", ".", "#", ".", "."],
[".", ".", ".", ".", "."],
]
return Zone(
name="test",
description="Test Zone",
width=5,
height=5,
terrain=terrain,
toroidal=False,
impassable={"#"},
)
@pytest.mark.asyncio
async def test_mob_outside_home_region_moves_toward_it(zone):
"""Mob outside home region should move one tile toward center."""
# Create mob at (4, 4) with home region center at (1, 1)
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# First movement should happen immediately
await process_mob_movement()
# Mob should move toward home (prefer larger distance axis)
# Distance to center (1,1): dx=3, dy=3 — equal, so prefer x-axis
assert mob.x == 3 and mob.y == 4
@pytest.mark.asyncio
async def test_mob_inside_home_region_does_not_move(zone):
"""Mob already inside home region should not move."""
mob = Mob(
name="test_mob",
location=zone,
x=1,
y=1,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
await process_mob_movement()
# Position should not change
assert mob.x == 1 and mob.y == 1
@pytest.mark.asyncio
async def test_mob_with_no_home_region_does_not_move(zone):
"""Mob with no home region should not move."""
mob = Mob(
name="test_mob",
location=zone,
x=2,
y=2,
)
mobs.append(mob)
await process_mob_movement()
# Position should not change
assert mob.x == 2 and mob.y == 2
@pytest.mark.asyncio
async def test_mob_in_combat_does_not_move(zone):
"""Mob in an encounter should not move."""
from mudlib.combat.encounter import CombatEncounter
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# Create a mock player for the encounter
from mudlib.player import Player
mock_writer = MagicMock()
mock_writer.is_closing.return_value = False
mock_writer.drain = AsyncMock()
player = Player(
name="test_player",
location=zone,
x=4,
y=4,
writer=mock_writer,
reader=MagicMock(),
)
# Create encounter
from mudlib.combat.engine import active_encounters
encounter = CombatEncounter(attacker=mob, defender=player)
active_encounters.append(encounter)
try:
await process_mob_movement()
# Mob should not move
assert mob.x == 4 and mob.y == 4
finally:
active_encounters.clear()
@pytest.mark.asyncio
async def test_movement_is_throttled(zone):
"""Mob should not move on every tick (throttle ~3 seconds)."""
mob = Mob(
name="test_mob",
location=zone,
x=4,
y=4,
home_x_min=0,
home_x_max=2,
home_y_min=0,
home_y_max=2,
)
mobs.append(mob)
# First move should happen
await process_mob_movement()
first_x, first_y = mob.x, mob.y
assert (first_x, first_y) != (4, 4) # Moved
# Immediate second call should not move
await process_mob_movement()
assert mob.x == first_x and mob.y == first_y
# After waiting 3 seconds, should move again
time.sleep(3.1)
await process_mob_movement()
assert mob.x != first_x or mob.y != first_y
@pytest.mark.asyncio
async def test_mob_respects_impassable_terrain(zone):
"""Mob should not move into impassable terrain."""
# Position mob at (1, 0) with home at (3, 0)
# There's a wall at (2, 1) but path around it
mob = Mob(
name="test_mob",
location=zone,
x=1,
y=0,
home_x_min=3,
home_x_max=4,
home_y_min=0,
home_y_max=1,
)
mobs.append(mob)
await process_mob_movement()
# Mob should move toward home (east)
assert mob.x == 2 and mob.y == 0
# Try to move south from (2,0) — wall at (2,1)
mob.x = 2
mob.y = 0
mob.home_x_min = 2
mob.home_x_max = 2
mob.home_y_min = 2
mob.home_y_max = 4
time.sleep(3.1) # Wait for throttle
await process_mob_movement()
# Should not have moved into wall (2,1 is '#')
# Should stay at (2, 0) since target (2,1) is blocked
assert mob.x == 2 and mob.y == 0
@pytest.mark.asyncio
async def test_movement_broadcasts_to_nearby_players(zone):
"""Mob movement should broadcast to nearby players."""
from mudlib.player import Player
mob = Mob(
name="test_mob",
location=zone,
x=2,
y=2,
home_x_min=0,
home_x_max=1,
home_y_min=0,
home_y_max=1,
)
mobs.append(mob)
# Create a player nearby
mock_writer = MagicMock()
mock_writer.is_closing.return_value = False
mock_writer.drain = AsyncMock()
player = Player(
name="test_player",
location=zone,
x=2,
y=2,
writer=mock_writer,
reader=MagicMock(),
)
await process_mob_movement()
# Player should receive a movement message
assert player.writer.write.called
# Check that message contains mob name and direction
call_args = [call[0][0] for call in player.writer.write.call_args_list]
movement_msg = "".join(call_args)
assert "test_mob" in movement_msg