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.
266 lines
7.5 KiB
Python
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
|