Each cloud in the trail gets a slightly longer TTL than the one before it (0.15s stagger). The origin cloud dissolves first, then each subsequent tile follows. Two consecutive flights produce a trail where the oldest clouds are already gone.
298 lines
7.5 KiB
Python
298 lines
7.5 KiB
Python
"""Tests for the fly command."""
|
|
|
|
import time
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from mudlib.commands import fly, look, movement
|
|
from mudlib.effects import active_effects
|
|
from mudlib.player import Player, players
|
|
|
|
|
|
@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 mock_world():
|
|
world = MagicMock()
|
|
world.width = 100
|
|
world.height = 100
|
|
world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100))
|
|
viewport = [["." for _ in range(21)] for _ in range(11)]
|
|
world.get_viewport = MagicMock(return_value=viewport)
|
|
return world
|
|
|
|
|
|
@pytest.fixture
|
|
def player(mock_reader, mock_writer):
|
|
return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clean_state(mock_world):
|
|
"""Clean global state before/after each test."""
|
|
fly.world = mock_world
|
|
look.world = mock_world
|
|
movement.world = mock_world
|
|
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):
|
|
"""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,
|
|
)
|
|
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):
|
|
"""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,
|
|
)
|
|
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, mock_world):
|
|
"""Flying should auto-look at the destination."""
|
|
players[player.name] = player
|
|
player.flying = True
|
|
await fly.cmd_fly(player, "east")
|
|
|
|
assert mock_world.get_viewport.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
|