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