From 9844749edd6f8faf7216db733c3a10c162bd2895 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 7 Feb 2026 14:14:09 -0500 Subject: [PATCH] Add fly command with cloud trail effects fly moves the player 5 tiles, ignoring terrain. Leaves a trail of bright white ~ clouds that fade after 2 seconds. Effects system supports arbitrary timed visual overlays on the viewport. TinTin aliases: fn/fs/fe/fw/fne/fnw/fse/fsw. --- mud.tin | 10 ++ src/mudlib/commands/fly.py | 78 +++++++++++++ src/mudlib/commands/look.py | 20 +++- src/mudlib/effects.py | 46 ++++++++ src/mudlib/server.py | 2 + tests/test_commands.py | 50 +++++++++ tests/test_effects.py | 84 ++++++++++++++ tests/test_fly.py | 216 ++++++++++++++++++++++++++++++++++++ 8 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 src/mudlib/commands/fly.py create mode 100644 src/mudlib/effects.py create mode 100644 tests/test_effects.py create mode 100644 tests/test_fly.py diff --git a/mud.tin b/mud.tin index 69548d0..05bf155 100644 --- a/mud.tin +++ b/mud.tin @@ -1,3 +1,13 @@ #NOP TinTin++ config for connecting to the MUD server #split 0 1 #session mud localhost 6789 + +#NOP fly aliases: f = fly 5 in that direction +#alias {fn} {fly north} +#alias {fs} {fly south} +#alias {fe} {fly east} +#alias {fw} {fly west} +#alias {fne} {fly northeast} +#alias {fnw} {fly northwest} +#alias {fse} {fly southeast} +#alias {fsw} {fly southwest} diff --git a/src/mudlib/commands/fly.py b/src/mudlib/commands/fly.py new file mode 100644 index 0000000..a8fa68e --- /dev/null +++ b/src/mudlib/commands/fly.py @@ -0,0 +1,78 @@ +"""Fly command for aerial movement across the world.""" + +from typing import Any + +from mudlib.commands import register +from mudlib.commands.movement import DIRECTIONS, send_nearby_message +from mudlib.effects import add_effect +from mudlib.player import Player +from mudlib.render.ansi import BOLD, BRIGHT_WHITE + +# World instance will be injected by the server +world: Any = None + +# how far you fly +FLY_DISTANCE = 5 + +# how long cloud trail lingers (seconds) +CLOUD_TTL = 2.0 + +# ANSI color for cloud trails +CLOUD_COLOR = BOLD + BRIGHT_WHITE + + +async def cmd_fly(player: Player, args: str) -> None: + """Fly through the air, moving 5 tiles in a direction. + + Args: + player: The player executing the command + args: Direction to fly (e.g. "east", "nw") + """ + direction = args.strip().lower() + if not direction: + player.writer.write("Fly which direction?\r\n") + await player.writer.drain() + return + + delta = DIRECTIONS.get(direction) + if delta is None: + player.writer.write(f"'{direction}' isn't a direction.\r\n") + await player.writer.drain() + return + + 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) + add_effect(trail_x, trail_y, "~", CLOUD_COLOR, ttl=CLOUD_TTL) + + # move player to destination + dest_x, dest_y = world.wrap( + start_x + dx * FLY_DISTANCE, start_y + dy * FLY_DISTANCE + ) + 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 + + await cmd_look(player, "") + + +register("fly", cmd_fly) diff --git a/src/mudlib/commands/look.py b/src/mudlib/commands/look.py index 777bb49..3347095 100644 --- a/src/mudlib/commands/look.py +++ b/src/mudlib/commands/look.py @@ -3,8 +3,9 @@ from typing import Any from mudlib.commands import register +from mudlib.effects import get_effects_at from mudlib.player import Player, players -from mudlib.render.ansi import colorize_terrain +from mudlib.render.ansi import RESET, colorize_terrain # World instance will be injected by the server world: Any = None @@ -53,6 +54,10 @@ async def cmd_look(player: Player, args: str) -> None: other_player_positions.append((rel_x, rel_y)) # Build the output with ANSI coloring + # priority: player @ > other players * > effects > terrain + half_width = VIEWPORT_WIDTH // 2 + half_height = VIEWPORT_HEIGHT // 2 + output_lines = [] for y, row in enumerate(viewport): line = [] @@ -64,7 +69,18 @@ async def cmd_look(player: Player, args: str) -> None: elif (x, y) in other_player_positions: line.append(colorize_terrain("*")) else: - line.append(colorize_terrain(tile)) + # Check for active effects at this world position + world_x, world_y = world.wrap( + player.x - half_width + x, + player.y - half_height + y, + ) + effects = get_effects_at(world_x, world_y) + if effects: + # use the most recent effect + e = effects[-1] + line.append(f"{e.color}{e.char}{RESET}") + else: + line.append(colorize_terrain(tile)) output_lines.append("".join(line)) # Send to player diff --git a/src/mudlib/effects.py b/src/mudlib/effects.py new file mode 100644 index 0000000..1b38f9f --- /dev/null +++ b/src/mudlib/effects.py @@ -0,0 +1,46 @@ +"""Temporary visual effects that appear on the map and fade over time.""" + +import time +from dataclasses import dataclass + + +@dataclass +class Effect: + """A temporary visual overlay on the world map.""" + + x: int + y: int + char: str + color: str # ANSI color code + expires_at: float # monotonic time when this effect disappears + + +# Global list of active effects +active_effects: list[Effect] = [] + + +def add_effect(x: int, y: int, char: str, color: str, ttl: float) -> None: + """Add a temporary visual effect at (x, y). + + Args: + x: World X coordinate + y: World Y coordinate + char: Character to display + color: ANSI color code for rendering + ttl: Time to live in seconds + """ + active_effects.append( + Effect(x=x, y=y, char=char, color=color, expires_at=time.monotonic() + ttl) + ) + + +def get_effects_at(x: int, y: int) -> list[Effect]: + """Get all non-expired effects at a given position.""" + now = time.monotonic() + return [e for e in active_effects if e.x == x and e.y == y and e.expires_at > now] + + +def clear_expired() -> None: + """Remove all expired effects from the active list.""" + now = time.monotonic() + active_effects[:] = [e for e in active_effects if e.expires_at > now] diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 7bc6e28..a31caeb 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -9,6 +9,7 @@ import telnetlib3 from telnetlib3.server_shell import readline2 import mudlib.commands +import mudlib.commands.fly import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit @@ -143,6 +144,7 @@ async def run_server() -> None: log.info("world generated in %.2fs", elapsed) # Inject world into command modules + mudlib.commands.fly.world = _world mudlib.commands.look.world = _world mudlib.commands.movement.world = _world diff --git a/tests/test_commands.py b/tests/test_commands.py index 04e2687..4581121 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -6,7 +6,9 @@ import pytest from mudlib import commands from mudlib.commands import look, movement +from mudlib.effects import active_effects, add_effect from mudlib.player import Player +from mudlib.render.ansi import RESET @pytest.fixture @@ -267,3 +269,51 @@ async def test_look_command_shows_other_players(player, mock_world): # Check that the output contains * for other players output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "*" in output + + +@pytest.mark.asyncio +async def test_look_shows_effects_on_viewport(player, mock_world): + """Test that active effects overlay on the viewport.""" + look.world = mock_world + + from mudlib.player import players + + players.clear() + players[player.name] = player + + active_effects.clear() + # place a cloud effect 2 tiles east of the player (viewport center is 10,5) + # player is at (5, 5), so effect at (7, 5) -> viewport (12, 5) + add_effect(7, 5, "~", "\033[1;97m", ttl=10.0) + + await look.cmd_look(player, "") + + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + # the output should contain our cloud color applied to ~ + # (bright white ~ instead of blue ~) + assert "\033[1;97m~" + RESET in output + + active_effects.clear() + + +@pytest.mark.asyncio +async def test_effects_dont_override_player_marker(player, mock_world): + """Effects at the player's position should not hide the @ marker.""" + look.world = mock_world + + from mudlib.player import players + + players.clear() + players[player.name] = player + + active_effects.clear() + # place an effect right at the player's position + add_effect(player.x, player.y, "~", "\033[1;97m", ttl=10.0) + + await look.cmd_look(player, "") + + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + # player @ should still be visible at center + assert "@" in output + + active_effects.clear() diff --git a/tests/test_effects.py b/tests/test_effects.py new file mode 100644 index 0000000..3c809a2 --- /dev/null +++ b/tests/test_effects.py @@ -0,0 +1,84 @@ +"""Tests for the temporary visual effects system.""" + +import time + +import pytest + +from mudlib.effects import ( + Effect, + active_effects, + add_effect, + clear_expired, + get_effects_at, +) + + +@pytest.fixture(autouse=True) +def clean_effects(): + """Clear effects before and after each test.""" + active_effects.clear() + yield + active_effects.clear() + + +def test_add_effect_creates_entry(): + add_effect(10, 20, "~", "\033[1;97m", ttl=2.0) + assert len(active_effects) == 1 + e = active_effects[0] + assert e.x == 10 + assert e.y == 20 + assert e.char == "~" + assert e.color == "\033[1;97m" + + +def test_add_effect_sets_expiry_from_ttl(): + before = time.monotonic() + add_effect(0, 0, "~", "", ttl=2.0) + after = time.monotonic() + e = active_effects[0] + assert before + 2.0 <= e.expires_at <= after + 2.0 + + +def test_get_effects_at_returns_matching(): + add_effect(5, 5, "~", "c1", ttl=2.0) + add_effect(5, 5, "~", "c2", ttl=2.0) + add_effect(6, 5, "~", "c3", ttl=2.0) + + at_5_5 = get_effects_at(5, 5) + assert len(at_5_5) == 2 + assert {e.color for e in at_5_5} == {"c1", "c2"} + + +def test_get_effects_at_excludes_expired(): + add_effect(5, 5, "~", "alive", ttl=10.0) + # manually insert an expired effect + active_effects.append( + Effect(x=5, y=5, char="~", color="dead", expires_at=time.monotonic() - 1.0) + ) + + at_5_5 = get_effects_at(5, 5) + assert len(at_5_5) == 1 + assert at_5_5[0].color == "alive" + + +def test_clear_expired_removes_old_effects(): + add_effect(1, 1, "~", "alive", ttl=10.0) + active_effects.append( + Effect(x=2, y=2, char="~", color="dead", expires_at=time.monotonic() - 1.0) + ) + + clear_expired() + assert len(active_effects) == 1 + assert active_effects[0].color == "alive" + + +def test_multiple_effects_along_path(): + """Simulates adding a cloud trail along a flight path.""" + for i in range(5): + add_effect(10 + i, 20, "~", "\033[1;97m", ttl=2.0) + + assert len(active_effects) == 5 + # all at y=20, x from 10-14 + for i, e in enumerate(active_effects): + assert e.x == 10 + i + assert e.y == 20 diff --git a/tests/test_fly.py b/tests/test_fly.py new file mode 100644 index 0000000..2f33c3e --- /dev/null +++ b/tests/test_fly.py @@ -0,0 +1,216 @@ +"""Tests for the fly command.""" + +import time +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands import fly, look, movement +from mudlib.effects import active_effects +from mudlib.player import Player, players + + +@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 mock_world(): + world = MagicMock() + world.width = 100 + world.height = 100 + world.wrap = MagicMock(side_effect=lambda x, y: (x % 100, y % 100)) + viewport = [["." for _ in range(21)] for _ in range(11)] + world.get_viewport = MagicMock(return_value=viewport) + return world + + +@pytest.fixture +def player(mock_reader, mock_writer): + return Player(name="shmup", x=50, y=50, reader=mock_reader, writer=mock_writer) + + +@pytest.fixture(autouse=True) +def clean_state(mock_world): + """Clean global state before/after each test.""" + fly.world = mock_world + look.world = mock_world + movement.world = mock_world + players.clear() + active_effects.clear() + yield + players.clear() + active_effects.clear() + + +# --- direction parsing --- + + +@pytest.mark.asyncio +async def test_fly_east_moves_5_tiles(player): + """fly east should move player 5 tiles east.""" + players[player.name] = player + 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 + 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 + 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 + players[player.name] = player + await fly.cmd_fly(player, "east") + + # 98 + 5 = 103, wraps to 3 + 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 + 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): + assert (50 + i, 50) in trail_positions + + +@pytest.mark.asyncio +async def test_cloud_trail_chars_are_tilde(player): + players[player.name] = player + await fly.cmd_fly(player, "east") + + for e in active_effects: + assert e.char == "~" + + +@pytest.mark.asyncio +async def test_cloud_trail_has_ttl(player): + """Cloud effects should expire after roughly 2 seconds.""" + players[player.name] = player + + before = time.monotonic() + await fly.cmd_fly(player, "east") + + for e in active_effects: + remaining = e.expires_at - before + assert 1.5 <= remaining <= 2.5 + + +@pytest.mark.asyncio +async def test_fly_diagonal_trail(player): + """Diagonal flight should leave trail at each intermediate step.""" + players[player.name] = player + 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 --- + + +@pytest.mark.asyncio +async def test_fly_sends_self_message(player, mock_writer): + """Player should see 'You fly into the air'.""" + 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) + + +@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 + + +@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 + 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, mock_world): + """Flying should auto-look at the destination.""" + players[player.name] = player + await fly.cmd_fly(player, "east") + + # get_viewport is called by look, which fly triggers + assert mock_world.get_viewport.called