diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index 29f57cd..72735c6 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -1,7 +1,6 @@ """Combat command handlers.""" import asyncio -import time from collections import defaultdict from pathlib import Path diff --git a/src/mudlib/commands/help.py b/src/mudlib/commands/help.py new file mode 100644 index 0000000..6240794 --- /dev/null +++ b/src/mudlib/commands/help.py @@ -0,0 +1,87 @@ +"""Help and command listing commands.""" + +from mudlib.commands import CommandDefinition, _registry, register +from mudlib.commands.movement import DIRECTIONS +from mudlib.player import Player + + +async def cmd_commands(player: Player, args: str) -> None: + """List all available commands grouped by type. + + Args: + player: The player executing the command + args: Command arguments (unused) + """ + # Collect unique commands by CommandDefinition id (avoids alias duplication) + seen: set[int] = set() + unique_commands: list[CommandDefinition] = [] + + for defn in _registry.values(): + defn_id = id(defn) + if defn_id not in seen: + seen.add(defn_id) + unique_commands.append(defn) + + # Group commands by type + movement: list[CommandDefinition] = [] + combat_attack: list[CommandDefinition] = [] + combat_defend: list[CommandDefinition] = [] + other: list[CommandDefinition] = [] + + for defn in unique_commands: + # Check if it's a movement command + if defn.name in DIRECTIONS: + movement.append(defn) + # Check if it's a combat attack + elif defn.help.startswith("Attack with"): + combat_attack.append(defn) + # Check if it's a combat defense + elif defn.help.startswith("Defend with"): + combat_defend.append(defn) + # Everything else + else: + other.append(defn) + + # Format output + output_lines = [] + + def format_command(defn: CommandDefinition) -> str: + """Format a command with its aliases in parens.""" + if defn.aliases: + aliases_str = ", ".join(defn.aliases) + return f"{defn.name}({aliases_str})" + return defn.name + + if movement: + movement.sort(key=lambda d: d.name) + movement_str = ", ".join(format_command(d) for d in movement) + output_lines.append(f"Movement: {movement_str}") + + if combat_attack: + combat_attack.sort(key=lambda d: d.name) + attack_str = ", ".join(format_command(d) for d in combat_attack) + output_lines.append(f"Combat (attack): {attack_str}") + + if combat_defend: + combat_defend.sort(key=lambda d: d.name) + defend_str = ", ".join(format_command(d) for d in combat_defend) + output_lines.append(f"Combat (defend): {defend_str}") + + if other: + other.sort(key=lambda d: d.name) + other_str = ", ".join(format_command(d) for d in other) + output_lines.append(f"Other: {other_str}") + + await player.send("\r\n".join(output_lines) + "\r\n") + + +# Register the commands command +register( + CommandDefinition( + "commands", + cmd_commands, + aliases=["cmds"], + mode="*", + help="list available commands", + ) +) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 1578868..2dbf1a3 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -13,6 +13,7 @@ from telnetlib3.server_shell import readline2 import mudlib.commands import mudlib.commands.edit import mudlib.commands.fly +import mudlib.commands.help import mudlib.commands.look import mudlib.commands.movement import mudlib.commands.quit diff --git a/tests/test_commands_list.py b/tests/test_commands_list.py new file mode 100644 index 0000000..6ec627f --- /dev/null +++ b/tests/test_commands_list.py @@ -0,0 +1,144 @@ +"""Tests for the commands listing command.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib import commands + +# Import command modules to register their commands +from mudlib.commands import ( + edit, # noqa: F401 + fly, # noqa: F401 + help, # noqa: F401 + look, # noqa: F401 + movement, # noqa: F401 + quit, # noqa: F401 +) + + +@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 player(mock_reader, mock_writer): + from mudlib.player import Player + + return Player( + name="TestPlayer", + x=5, + y=5, + reader=mock_reader, + writer=mock_writer, + ) + + +@pytest.mark.asyncio +async def test_commands_command_exists(): + """Test that commands command is registered.""" + assert "commands" in commands._registry + assert "cmds" in commands._registry + assert commands._registry["commands"] is commands._registry["cmds"] + + +@pytest.mark.asyncio +async def test_commands_has_wildcard_mode(): + """Test that commands works from any mode.""" + assert commands._registry["commands"].mode == "*" + + +@pytest.mark.asyncio +async def test_commands_lists_all_commands(player): + """Test that commands command lists all unique commands.""" + # Execute the command + await commands.dispatch(player, "commands") + + # Collect all output + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Should contain at least movement and other groups + assert "Movement:" in output + assert "Other:" in output + + # Should show some specific commands with their primary names + assert "north" in output + assert "look" in output + assert "quit" in output + assert "commands" in output + + +@pytest.mark.asyncio +async def test_commands_shows_aliases(player): + """Test that aliases are shown in parens after command names.""" + await commands.dispatch(player, "commands") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Check that some aliases are shown in parentheses + assert "north(n)" in output or "north (n)" in output + assert "look(l)" in output or "look (l)" in output + assert "quit(q)" in output or "quit (q)" in output + assert "commands(cmds)" in output or "commands (cmds)" in output + + +@pytest.mark.asyncio +async def test_commands_deduplicates_entries(player): + """Test that aliases don't create duplicate command entries.""" + await commands.dispatch(player, "commands") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Count how many times "north" appears as a primary command + # It should appear once in the movement group, not multiple times + # (Note: it might appear in parens as part of another command, but + # we're checking it doesn't appear as a standalone entry multiple times) + lines_with_north_command = [ + line for line in output.split("\n") if line.strip().startswith("north") + ] + # Should only have one line starting with "north" as a command + assert len(lines_with_north_command) <= 1 + + +@pytest.mark.asyncio +async def test_commands_groups_correctly(player): + """Test that commands are grouped by type.""" + await commands.dispatch(player, "commands") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Find the groups + movement_pos = output.find("Movement:") + other_pos = output.find("Other:") + + # At minimum, movement and other should exist + assert movement_pos != -1 + assert other_pos != -1 + + # Check that movement commands appear in the movement section + # (before the "Other:" section) + movement_section = output[movement_pos:other_pos] + assert "north" in movement_section + assert "east" in movement_section + + # Check that non-movement commands appear in other section + other_section = output[other_pos:] + assert "quit" in other_section + assert "look" in other_section + + +@pytest.mark.asyncio +async def test_commands_via_alias(player): + """Test that the cmds alias works.""" + await commands.dispatch(player, "cmds") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Should produce the same output structure + assert "Movement:" in output + assert "Other:" in output