Add fly command with cloud trail effects

fly <direction> 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.
This commit is contained in:
Jared Miller 2026-02-07 14:14:09 -05:00
parent 29983776f9
commit 9844749edd
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
8 changed files with 504 additions and 2 deletions

10
mud.tin
View file

@ -1,3 +1,13 @@
#NOP TinTin++ config for connecting to the MUD server
#split 0 1
#session mud localhost 6789
#NOP fly aliases: f<direction> = 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}

View file

@ -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)

View file

@ -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

46
src/mudlib/effects.py Normal file
View file

@ -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]

View file

@ -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

View file

@ -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()

84
tests/test_effects.py Normal file
View file

@ -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

216
tests/test_fly.py Normal file
View file

@ -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