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:
parent
9844749edd
commit
93ad4523e2
3 changed files with 150 additions and 61 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue