Add commands command to list available commands

This commit is contained in:
Jared Miller 2026-02-08 12:35:50 -05:00
parent e9378bb6fa
commit 6fb48e9ae1
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 232 additions and 1 deletions

View file

@ -1,7 +1,6 @@
"""Combat command handlers.""" """Combat command handlers."""
import asyncio import asyncio
import time
from collections import defaultdict from collections import defaultdict
from pathlib import Path from pathlib import Path

View file

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

View file

@ -13,6 +13,7 @@ from telnetlib3.server_shell import readline2
import mudlib.commands import mudlib.commands
import mudlib.commands.edit import mudlib.commands.edit
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.help
import mudlib.commands.look import mudlib.commands.look
import mudlib.commands.movement import mudlib.commands.movement
import mudlib.commands.quit import mudlib.commands.quit

144
tests/test_commands_list.py Normal file
View file

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