mud/tests/test_commands.py
Jared Miller 0d0c142993
Add seed-based terrain world with movement and viewport
1000x1000 tile world generated deterministically from a seed using
layered Perlin noise. Terrain derived from elevation: mountains,
forests, grasslands, sand, water, with rivers traced downhill from
peaks. ANSI-colored viewport centered on player.

Command system with registry/dispatch, 8-direction movement (n/s/e/w
+ diagonals), look/l, quit/q. Players see arrival/departure messages.

Set connect_maxwait=0.5 on telnetlib3 to avoid the 4s CHARSET
negotiation timeout — MUD clients reject CHARSET immediately via MTTS.
2026-02-07 13:27:44 -05:00

266 lines
7.5 KiB
Python

"""Tests for the command system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib import commands
from mudlib.commands import look, movement
from mudlib.player import Player
@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.is_passable = MagicMock(return_value=True)
# Create a 21x11 viewport filled with "."
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="TestPlayer", x=5, y=5, reader=mock_reader, writer=mock_writer)
# Test command registration
def test_register_command():
"""Test that commands can be registered."""
async def test_handler(player, args):
pass
commands.register("test", test_handler)
assert "test" in commands._registry
def test_register_command_with_aliases():
"""Test that command aliases work."""
async def test_handler(player, args):
pass
commands.register("testcmd", test_handler, aliases=["tc", "t"])
assert "testcmd" in commands._registry
assert "tc" in commands._registry
assert "t" in commands._registry
assert commands._registry["testcmd"] == commands._registry["tc"]
assert commands._registry["testcmd"] == commands._registry["t"]
@pytest.mark.asyncio
async def test_dispatch_routes_to_handler(player):
"""Test that dispatch routes input to the correct handler."""
called = False
received_args = None
async def test_handler(p, args):
nonlocal called, received_args
called = True
received_args = args
commands.register("testcmd", test_handler)
await commands.dispatch(player, "testcmd arg1 arg2")
assert called
assert received_args == "arg1 arg2"
@pytest.mark.asyncio
async def test_dispatch_handles_unknown_command(player, mock_writer):
"""Test that unknown commands give feedback."""
await commands.dispatch(player, "unknowncommand")
# Should have written some kind of error message
assert mock_writer.write.called
error_msg = mock_writer.write.call_args[0][0]
assert "unknown" in error_msg.lower() or "not found" in error_msg.lower()
@pytest.mark.asyncio
async def test_dispatch_handles_empty_input(player):
"""Test that empty input doesn't crash."""
await commands.dispatch(player, "")
await commands.dispatch(player, " ")
# Test movement direction parsing
@pytest.mark.parametrize(
"direction,expected_delta",
[
("n", (0, -1)),
("north", (0, -1)),
("s", (0, 1)),
("south", (0, 1)),
("e", (1, 0)),
("east", (1, 0)),
("w", (-1, 0)),
("west", (-1, 0)),
("ne", (1, -1)),
("northeast", (1, -1)),
("nw", (-1, -1)),
("northwest", (-1, -1)),
("se", (1, 1)),
("southeast", (1, 1)),
("sw", (-1, 1)),
("southwest", (-1, 1)),
],
)
def test_direction_deltas(direction, expected_delta):
"""Test that all direction commands map to correct deltas."""
assert movement.DIRECTIONS[direction] == expected_delta
@pytest.mark.asyncio
async def test_movement_updates_position(player, mock_world):
"""Test that movement updates player position when passable."""
# Inject mock world into both movement and look modules
movement.world = mock_world
look.world = mock_world
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
assert player.x == original_x
assert player.y == original_y - 1
assert mock_world.is_passable.called
@pytest.mark.asyncio
async def test_movement_blocked_by_impassable_terrain(player, mock_world, mock_writer):
"""Test that movement is blocked by impassable terrain."""
mock_world.is_passable.return_value = False
movement.world = mock_world
original_x, original_y = player.x, player.y
await movement.move_north(player, "")
# Position should not change
assert player.x == original_x
assert player.y == original_y
# Should send a message to the player
assert mock_writer.write.called
error_msg = mock_writer.write.call_args[0][0]
assert "can't" in error_msg.lower() or "cannot" in error_msg.lower()
@pytest.mark.asyncio
async def test_movement_sends_departure_message(player, mock_world):
"""Test that movement sends departure message to nearby players."""
movement.world = mock_world
look.world = mock_world
# Create another player in the area
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other_player = Player(
name="OtherPlayer", x=5, y=4, reader=MagicMock(), writer=other_writer
)
# Register both players
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
await movement.move_north(player, "")
# Other player should have received a departure message
# (We'll check this is called, exact message format is implementation detail)
assert other_player.writer.write.called
@pytest.mark.asyncio
async def test_arrival_message_uses_opposite_direction(player, mock_world):
"""Test that arrival messages use the opposite direction."""
movement.world = mock_world
look.world = mock_world
# Create another player at the destination
other_writer = MagicMock()
other_writer.write = MagicMock()
other_writer.drain = AsyncMock()
other_player = Player(
name="OtherPlayer", x=5, y=3, reader=MagicMock(), writer=other_writer
)
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
# Player at (5, 5) moves north to (5, 4)
await movement.move_north(player, "")
# Other player at (5, 3) should see arrival "from the south"
# (Implementation will determine exact message format)
assert other_player.writer.write.called
# Test look command
@pytest.mark.asyncio
async def test_look_command_sends_viewport(player, mock_world):
"""Test that look command sends the viewport to the player."""
look.world = mock_world
await look.cmd_look(player, "")
assert mock_world.get_viewport.called
assert player.writer.write.called
@pytest.mark.asyncio
async def test_look_command_shows_player_at_center(player, mock_world):
"""Test that look command shows player @ at center."""
look.world = mock_world
await look.cmd_look(player, "")
# Check that the output contains the @ symbol for the player
output = "".join([call[0][0] for call in player.writer.write.call_args_list])
assert "@" in output
@pytest.mark.asyncio
async def test_look_command_shows_other_players(player, mock_world):
"""Test that look command shows other players as *."""
look.world = mock_world
# Create another player in the viewport
other_player = Player(
name="OtherPlayer",
x=6,
y=5,
reader=MagicMock(),
writer=MagicMock(),
)
from mudlib.player import players
players.clear()
players[player.name] = player
players[other_player.name] = other_player
await look.cmd_look(player, "")
# 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