mud/tests/test_fly.py
Jared Miller 404a1cdf0c
Migrate movement to use player.location (Zone)
Movement commands now access the zone through player.location instead of
a module-level world variable. send_nearby_message uses
zone.contents_near() to find nearby entities, eliminating the need for
the global players dict and manual distance calculations.

Tests updated to create zones and add entities via location assignment.
2026-02-11 19:28:27 -05:00

309 lines
7.8 KiB
Python

"""Tests for the fly command."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import fly, look
from mudlib.effects import active_effects
from mudlib.player import Player, players
from mudlib.zone import Zone
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
@pytest.fixture
def test_zone():
terrain = [["." for _ in range(100)] for _ in range(100)]
zone = Zone(
name="testzone",
width=100,
height=100,
toroidal=True,
terrain=terrain,
impassable=set(),
)
return zone
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
p = Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
p.location = test_zone
test_zone._contents.append(p)
return p
@pytest.fixture(autouse=True)
def clean_state(test_zone):
"""Clean global state before/after each test."""
fly.world = test_zone
look.world = test_zone
players.clear()
active_effects.clear()
yield
players.clear()
active_effects.clear()
# --- toggle ---
@pytest.mark.asyncio
async def test_fly_toggles_on(player, mock_writer):
"""fly with no args sets flying=True."""
players[player.name] = player
assert not player.flying
await fly.cmd_fly(player, "")
assert player.flying
calls = [str(c) for c in mock_writer.write.call_args_list]
assert any("fly" in c.lower() and "air" in c.lower() for c in calls)
@pytest.mark.asyncio
async def test_fly_toggles_off(player, mock_writer):
"""fly again sets flying=False."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "")
assert not player.flying
calls = [str(c) for c in mock_writer.write.call_args_list]
assert any("land" in c.lower() for c in calls)
@pytest.mark.asyncio
async def test_fly_toggle_on_notifies_nearby(player, test_zone):
"""Others see liftoff message."""
players[player.name] = player
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other = Player(
name="bystander",
x=52,
y=50,
reader=MagicMock(),
writer=other_writer,
)
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other
await fly.cmd_fly(player, "")
calls = [str(c) for c in other_writer.write.call_args_list]
assert any("shmup" in c.lower() and "lifts" in c.lower() for c in calls)
@pytest.mark.asyncio
async def test_fly_toggle_off_notifies_nearby(player, test_zone):
"""Others see landing message."""
players[player.name] = player
player.flying = True
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other = Player(
name="bystander",
x=52,
y=50,
reader=MagicMock(),
writer=other_writer,
)
other.location = test_zone
test_zone._contents.append(other)
players[other.name] = other
await fly.cmd_fly(player, "")
calls = [str(c) for c in other_writer.write.call_args_list]
assert any("shmup" in c.lower() and "lands" in c.lower() for c in calls)
# --- must be flying to move ---
@pytest.mark.asyncio
async def test_fly_direction_without_flying_errors(player, mock_writer):
"""fly east when not flying gives an error."""
players[player.name] = player
assert not player.flying
await fly.cmd_fly(player, "east")
calls = [str(c) for c in mock_writer.write.call_args_list]
assert any("aren't flying" in c.lower() for c in calls)
assert player.x == 50
assert player.y == 50
# --- movement while flying ---
@pytest.mark.asyncio
async def test_fly_east_moves_5_tiles(player):
"""fly east should move player 5 tiles east."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
assert player.x == 55
assert player.y == 50
@pytest.mark.asyncio
async def test_fly_north_moves_5_tiles(player):
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "north")
assert player.x == 50
assert player.y == 45
@pytest.mark.asyncio
async def test_fly_northwest_moves_5_diagonal(player):
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "nw")
assert player.x == 45
assert player.y == 45
@pytest.mark.asyncio
async def test_fly_wraps_around_world(player):
"""Flying near an edge should wrap toroidally."""
player.x = 98
player.y = 50
player.flying = True
players[player.name] = player
await fly.cmd_fly(player, "east")
assert player.x == 3
assert player.y == 50
# --- cloud trail ---
@pytest.mark.asyncio
async def test_fly_creates_cloud_trail(player):
"""Flying should leave ~ effects along the path."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
assert len(active_effects) == 5
trail_positions = [(e.x, e.y) for e in active_effects]
for i in range(5):
assert (50 + i, 50) in trail_positions
@pytest.mark.asyncio
async def test_cloud_trail_chars_are_tilde(player):
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
for e in active_effects:
assert e.char == "~"
@pytest.mark.asyncio
async def test_cloud_trail_dissolves_from_origin(player):
"""Origin cloud expires first, trail shrinks toward player."""
players[player.name] = player
player.flying = True
before = time.monotonic()
await fly.cmd_fly(player, "east")
# effects are in path order (step 0..4)
expiries = [e.expires_at for e in active_effects]
# each should expire after the previous one (origin fades first)
for i in range(1, len(expiries)):
assert expiries[i] > expiries[i - 1]
# origin cloud ~1.5s, near-dest cloud ~1.5 + 4*0.4 = ~3.1s
assert 1.3 <= expiries[0] - before <= 1.7
assert 2.9 <= expiries[-1] - before <= 3.3
@pytest.mark.asyncio
async def test_fly_diagonal_trail(player):
"""Diagonal flight should leave trail at each intermediate step."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "se")
assert len(active_effects) == 5
trail_positions = [(e.x, e.y) for e in active_effects]
for i in range(5):
assert (50 + i, 50 + i) in trail_positions
# --- no movement without flying leaves no trail ---
@pytest.mark.asyncio
async def test_no_trail_when_not_flying(player):
"""Trying to fly a direction while grounded creates no effects."""
players[player.name] = player
await fly.cmd_fly(player, "east")
assert len(active_effects) == 0
# --- error cases ---
@pytest.mark.asyncio
async def test_fly_bad_direction_gives_error(player, mock_writer):
"""fly with an invalid direction should give feedback."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "sideways")
calls = [str(c) for c in mock_writer.write.call_args_list]
assert any("direction" in c.lower() for c in calls)
assert player.x == 50
assert player.y == 50
@pytest.mark.asyncio
async def test_fly_triggers_look(player, test_zone):
"""Flying should auto-look at the destination."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
# look was called (check that writer was written to)
assert player.writer.write.called
@pytest.mark.asyncio
async def test_stays_flying_after_move(player):
"""Moving while flying doesn't turn off flying."""
players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east")
assert player.flying