Make flying a toggle state

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.
This commit is contained in:
Jared Miller 2026-02-07 14:15:15 -05:00
parent 9844749edd
commit 93ad4523e2
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 150 additions and 61 deletions

View file

@ -22,15 +22,40 @@ CLOUD_COLOR = BOLD + BRIGHT_WHITE
async def cmd_fly(player: Player, args: str) -> None: 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: fly - toggle flying on/off
player: The player executing the command fly <dir> - move 5 tiles in a direction (must be flying)
args: Direction to fly (e.g. "east", "nw")
""" """
direction = args.strip().lower() direction = args.strip().lower()
# no args = toggle flying state
if not direction: 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() await player.writer.drain()
return return
@ -43,15 +68,6 @@ async def cmd_fly(player: Player, args: str) -> None:
dx, dy = delta dx, dy = delta
start_x, start_y = player.x, player.y 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 # lay cloud trail at starting position and each intermediate step
for step in range(FLY_DISTANCE): for step in range(FLY_DISTANCE):
trail_x, trail_y = world.wrap(start_x + dx * step, start_y + dy * step) 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.x = dest_x
player.y = dest_y 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 # auto-look at new position
from mudlib.commands.look import cmd_look from mudlib.commands.look import cmd_look

View file

@ -13,6 +13,7 @@ class Player:
y: int y: int
writer: Any # telnetlib3 TelnetWriter for sending output writer: Any # telnetlib3 TelnetWriter for sending output
reader: Any # telnetlib3 TelnetReader for reading input reader: Any # telnetlib3 TelnetReader for reading input
flying: bool = False
# Global registry of connected players # Global registry of connected players

View file

@ -52,13 +52,107 @@ def clean_state(mock_world):
active_effects.clear() 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 @pytest.mark.asyncio
async def test_fly_east_moves_5_tiles(player): async def test_fly_east_moves_5_tiles(player):
"""fly east should move player 5 tiles east.""" """fly east should move player 5 tiles east."""
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
assert player.x == 55 assert player.x == 55
@ -68,6 +162,7 @@ async def test_fly_east_moves_5_tiles(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_north_moves_5_tiles(player): async def test_fly_north_moves_5_tiles(player):
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "north") await fly.cmd_fly(player, "north")
assert player.x == 50 assert player.x == 50
@ -77,6 +172,7 @@ async def test_fly_north_moves_5_tiles(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_northwest_moves_5_diagonal(player): async def test_fly_northwest_moves_5_diagonal(player):
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "nw") await fly.cmd_fly(player, "nw")
assert player.x == 45 assert player.x == 45
@ -88,10 +184,10 @@ async def test_fly_wraps_around_world(player):
"""Flying near an edge should wrap toroidally.""" """Flying near an edge should wrap toroidally."""
player.x = 98 player.x = 98
player.y = 50 player.y = 50
player.flying = True
players[player.name] = player players[player.name] = player
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
# 98 + 5 = 103, wraps to 3
assert player.x == 3 assert player.x == 3
assert player.y == 50 assert player.y == 50
@ -103,10 +199,9 @@ async def test_fly_wraps_around_world(player):
async def test_fly_creates_cloud_trail(player): async def test_fly_creates_cloud_trail(player):
"""Flying should leave ~ effects along the path.""" """Flying should leave ~ effects along the path."""
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east") 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 assert len(active_effects) == 5
trail_positions = [(e.x, e.y) for e in active_effects] trail_positions = [(e.x, e.y) for e in active_effects]
for i in range(5): for i in range(5):
@ -116,6 +211,7 @@ async def test_fly_creates_cloud_trail(player):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_cloud_trail_chars_are_tilde(player): async def test_cloud_trail_chars_are_tilde(player):
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
for e in active_effects: 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): async def test_cloud_trail_has_ttl(player):
"""Cloud effects should expire after roughly 2 seconds.""" """Cloud effects should expire after roughly 2 seconds."""
players[player.name] = player players[player.name] = player
player.flying = True
before = time.monotonic() before = time.monotonic()
await fly.cmd_fly(player, "east") 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): async def test_fly_diagonal_trail(player):
"""Diagonal flight should leave trail at each intermediate step.""" """Diagonal flight should leave trail at each intermediate step."""
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "se") 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 assert len(active_effects) == 5
trail_positions = [(e.x, e.y) for e in active_effects] trail_positions = [(e.x, e.y) for e in active_effects]
for i in range(5): for i in range(5):
assert (50 + i, 50 + i) in trail_positions assert (50 + i, 50 + i) in trail_positions
# --- messages --- # --- no movement without flying leaves no trail ---
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_sends_self_message(player, mock_writer): async def test_no_trail_when_not_flying(player):
"""Player should see 'You fly into the air'.""" """Trying to fly a direction while grounded creates no effects."""
players[player.name] = player players[player.name] = player
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
calls = [str(c) for c in mock_writer.write.call_args_list] assert len(active_effects) == 0
assert any("fly" in c.lower() and "air" in c.lower() for c in calls)
@pytest.mark.asyncio # --- error cases ---
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
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_fly_bad_direction_gives_error(player, mock_writer): async def test_fly_bad_direction_gives_error(player, mock_writer):
"""fly with an invalid direction should give feedback.""" """fly with an invalid direction should give feedback."""
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "sideways") await fly.cmd_fly(player, "sideways")
calls = [str(c) for c in mock_writer.write.call_args_list] 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): async def test_fly_triggers_look(player, mock_world):
"""Flying should auto-look at the destination.""" """Flying should auto-look at the destination."""
players[player.name] = player players[player.name] = player
player.flying = True
await fly.cmd_fly(player, "east") await fly.cmd_fly(player, "east")
# get_viewport is called by look, which fly triggers
assert mock_world.get_viewport.called 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