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:
parent
e6ca4dc6b1
commit
75da6871ba
3 changed files with 371 additions and 1 deletions
|
|
@ -6,13 +6,22 @@ import time
|
|||
from mudlib.combat.encounter import CombatState
|
||||
from mudlib.combat.engine import get_encounter
|
||||
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.render.colors import colorize
|
||||
from mudlib.render.pov import render_pov
|
||||
from mudlib.zone import Zone
|
||||
|
||||
# Seconds between mob actions (gives player time to read and react)
|
||||
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:
|
||||
"""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)
|
||||
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
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ from mudlib.gmcp import (
|
|||
send_room_info,
|
||||
)
|
||||
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.player import Player, players
|
||||
from mudlib.prompt import render_prompt
|
||||
|
|
@ -101,6 +101,7 @@ async def game_loop() -> None:
|
|||
clear_expired()
|
||||
await process_combat()
|
||||
await process_mobs(mudlib.combat.commands.combat_moves)
|
||||
await process_mob_movement()
|
||||
await process_resting()
|
||||
await process_unconscious()
|
||||
await process_decomposing()
|
||||
|
|
|
|||
259
tests/test_mob_pathfinding.py
Normal file
259
tests/test_mob_pathfinding.py
Normal 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
|
||||
Loading…
Reference in a new issue