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.
309 lines
7.8 KiB
Python
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
|