diff --git a/src/mudlib/commands/fly.py b/src/mudlib/commands/fly.py
index a8fa68e..8146753 100644
--- a/src/mudlib/commands/fly.py
+++ b/src/mudlib/commands/fly.py
@@ -22,15 +22,40 @@ CLOUD_COLOR = BOLD + BRIGHT_WHITE
async def cmd_fly(player: Player, args: str) -> None:
- """Fly through the air, moving 5 tiles in a direction.
+ """Toggle flying or move while airborne.
- Args:
- player: The player executing the command
- args: Direction to fly (e.g. "east", "nw")
+ fly - toggle flying on/off
+ fly
- move 5 tiles in a direction (must be flying)
"""
direction = args.strip().lower()
+
+ # no args = toggle flying state
if not direction:
- player.writer.write("Fly which direction?\r\n")
+ if player.flying:
+ player.flying = False
+ player.writer.write("You land.\r\n")
+ await player.writer.drain()
+ await send_nearby_message(
+ player,
+ player.x,
+ player.y,
+ f"{player.name} lands.\r\n",
+ )
+ else:
+ player.flying = True
+ player.writer.write("You fly into the air!\r\n")
+ await player.writer.drain()
+ await send_nearby_message(
+ player,
+ player.x,
+ player.y,
+ f"{player.name} lifts into the air!\r\n",
+ )
+ return
+
+ # direction given but not flying
+ if not player.flying:
+ player.writer.write("You aren't flying.\r\n")
await player.writer.drain()
return
@@ -43,15 +68,6 @@ async def cmd_fly(player: Player, args: str) -> None:
dx, dy = delta
start_x, start_y = player.x, player.y
- # tell the player
- player.writer.write("You fly into the air!\r\n")
- await player.writer.drain()
-
- # tell nearby players at departure
- await send_nearby_message(
- player, player.x, player.y, f"{player.name} lifts into the air!\r\n"
- )
-
# lay cloud trail at starting position and each intermediate step
for step in range(FLY_DISTANCE):
trail_x, trail_y = world.wrap(start_x + dx * step, start_y + dy * step)
@@ -64,11 +80,6 @@ async def cmd_fly(player: Player, args: str) -> None:
player.x = dest_x
player.y = dest_y
- # tell nearby players at arrival
- await send_nearby_message(
- player, player.x, player.y, f"{player.name} lands from the sky!\r\n"
- )
-
# auto-look at new position
from mudlib.commands.look import cmd_look
diff --git a/src/mudlib/player.py b/src/mudlib/player.py
index 30a924b..5509bb3 100644
--- a/src/mudlib/player.py
+++ b/src/mudlib/player.py
@@ -13,6 +13,7 @@ class Player:
y: int
writer: Any # telnetlib3 TelnetWriter for sending output
reader: Any # telnetlib3 TelnetReader for reading input
+ flying: bool = False
# Global registry of connected players
diff --git a/tests/test_fly.py b/tests/test_fly.py
index 2f33c3e..6a2c542 100644
--- a/tests/test_fly.py
+++ b/tests/test_fly.py
@@ -52,13 +52,107 @@ def clean_state(mock_world):
active_effects.clear()
-# --- direction parsing ---
+# --- 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
@@ -68,6 +162,7 @@ async def test_fly_east_moves_5_tiles(player):
@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
@@ -77,6 +172,7 @@ async def test_fly_north_moves_5_tiles(player):
@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
@@ -88,10 +184,10 @@ 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")
- # 98 + 5 = 103, wraps to 3
assert player.x == 3
assert player.y == 50
@@ -103,10 +199,9 @@ async def test_fly_wraps_around_world(player):
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")
- # trail at positions 50,51,52,53,54 (start through second-to-last)
- # player ends at 55
assert len(active_effects) == 5
trail_positions = [(e.x, e.y) for e in active_effects]
for i in range(5):
@@ -116,6 +211,7 @@ async def test_fly_creates_cloud_trail(player):
@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:
@@ -126,6 +222,7 @@ async def test_cloud_trail_chars_are_tilde(player):
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")
@@ -139,65 +236,35 @@ async def test_cloud_trail_has_ttl(player):
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")
- # southeast: dx=1, dy=1, 5 steps
- # trail at (50,50), (51,51), (52,52), (53,53), (54,54)
- # player ends at (55, 55)
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
-# --- messages ---
+# --- no movement without flying leaves no trail ---
@pytest.mark.asyncio
-async def test_fly_sends_self_message(player, mock_writer):
- """Player should see 'You fly into the air'."""
+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")
- 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)
+ assert len(active_effects) == 0
-@pytest.mark.asyncio
-async def test_fly_sends_nearby_message(player):
- """Other players should see '{name} lifts into the air'."""
- 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, "east")
-
- 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_no_direction_gives_error(player, mock_writer):
- """fly with no args should tell the player to specify a direction."""
- players[player.name] = player
- await fly.cmd_fly(player, "")
-
- 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
+# --- 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]
@@ -210,7 +277,17 @@ async def test_fly_bad_direction_gives_error(player, mock_writer):
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")
- # get_viewport is called by look, which fly triggers
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