fly with no args toggles flying on/off. Movement commands (fly east, etc) only work while airborne. "You aren't flying." if you try to move without toggling on first. Player.flying field tracks the state.
293 lines
7.2 KiB
Python
293 lines
7.2 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_has_ttl(player):
|
|
"""Cloud effects should expire after roughly 2 seconds."""
|
|
players[player.name] = player
|
|
player.flying = True
|
|
|
|
before = time.monotonic()
|
|
await fly.cmd_fly(player, "east")
|
|
|
|
for e in active_effects:
|
|
remaining = e.expires_at - before
|
|
assert 1.5 <= remaining <= 2.5
|
|
|
|
|
|
@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
|