From 1b63f87da72a1bc049e718df7eb1da26060fbd82 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 12:41:02 -0500 Subject: [PATCH] Add detail view to commands command --- src/mudlib/commands/help.py | 157 +++++++++++++++++++++++++++++++++++- tests/test_commands_list.py | 111 +++++++++++++++++++++++++ 2 files changed, 266 insertions(+), 2 deletions(-) diff --git a/src/mudlib/commands/help.py b/src/mudlib/commands/help.py index 6240794..55d18e9 100644 --- a/src/mudlib/commands/help.py +++ b/src/mudlib/commands/help.py @@ -1,17 +1,170 @@ """Help and command listing commands.""" +from typing import TYPE_CHECKING + from mudlib.commands import CommandDefinition, _registry, register from mudlib.commands.movement import DIRECTIONS from mudlib.player import Player +if TYPE_CHECKING: + from mudlib.combat.moves import CombatMove + + +async def _show_command_detail(player: Player, command_name: str) -> None: + """Show detailed information about a specific command. + + Args: + player: The player requesting details + command_name: Name or alias of the command to show + """ + from mudlib.combat.commands import combat_moves + + # Look up the command in registry + defn = _registry.get(command_name) + + # If not in registry, check if it's a combat move name (like "punch left") + if defn is None: + move = combat_moves.get(command_name) + if move is not None: + # Create a synthetic defn for display purposes + defn = CommandDefinition( + name=move.name, + handler=lambda p, a: None, + aliases=move.aliases, + mode="*", + help="", + ) + await _show_single_command(player, defn, move) + return + + await player.send(f"Unknown command: {command_name}\r\n") + return + + # Try to get combat move data + move = combat_moves.get(defn.name) + + # Check if this is a variant base command (like "punch") + # by looking for variants in combat_moves + variants = [] + if move is None: + for _move_name, m in combat_moves.items(): + if m.command == defn.name and m.variant: + variants.append(m) + + # If we found variants, show variant overview + if variants: + await _show_variant_overview(player, defn, variants) + return + + # Otherwise show single command detail + await _show_single_command(player, defn, move) + + +async def _show_single_command( + player: Player, defn: CommandDefinition, move: "CombatMove | None" +) -> None: + """Show details for a single command or combat move. + + Args: + player: The player requesting details + defn: Command definition + move: Combat move data if available + """ + + lines = [defn.name] + + # Always show aliases + if defn.aliases: + aliases_str = ", ".join(defn.aliases) + lines.append(f" aliases: {aliases_str}") + + # Show type for combat moves + if move is not None: + lines.append(f" type: {move.move_type}") + + # Show mode for non-wildcard modes + if defn.mode != "*": + lines.append(f" mode: {defn.mode}") + + # Combat move specific details + if move is not None: + lines.append(f" stamina: {move.stamina_cost}") + lines.append(f" timing window: {move.timing_window_ms}ms") + if move.damage_pct > 0: + damage_pct = int(move.damage_pct * 100) + lines.append(f" damage: {damage_pct}%") + if move.telegraph: + lines.append(f" telegraph: {move.telegraph}") + if move.countered_by: + counters = ", ".join(move.countered_by) + lines.append(f" countered by: {counters}") + else: + # Show help text for non-combat commands + if defn.help: + lines.append(f" help: {defn.help}") + + await player.send("\r\n".join(lines) + "\r\n") + + +async def _show_variant_overview( + player: Player, defn: CommandDefinition, variants: list["CombatMove"] +) -> None: + """Show overview of a variant command with all its variants. + + Args: + player: The player requesting details + defn: Command definition for the base command + variants: List of variant moves + """ + + # Sort variants by name for consistent display + variants = sorted(variants, key=lambda m: m.name) + + lines = [defn.name] + + # Show type from first variant + if variants: + lines.append(f" type: {variants[0].move_type}") + + # Show each variant + for move in variants: + lines.append("") + lines.append(f" {move.name}") + + if move.aliases: + aliases_str = ", ".join(move.aliases) + lines.append(f" aliases: {aliases_str}") + + lines.append(f" stamina: {move.stamina_cost}") + lines.append(f" timing window: {move.timing_window_ms}ms") + + if move.damage_pct > 0: + damage_pct = int(move.damage_pct * 100) + lines.append(f" damage: {damage_pct}%") + + if move.telegraph: + lines.append(f" telegraph: {move.telegraph}") + + if move.countered_by: + counters = ", ".join(move.countered_by) + lines.append(f" countered by: {counters}") + + await player.send("\r\n".join(lines) + "\r\n") + async def cmd_commands(player: Player, args: str) -> None: - """List all available commands grouped by type. + """List all available commands grouped by type, or show detail for one. Args: player: The player executing the command - args: Command arguments (unused) + args: Command arguments (command name for detail view, empty for list) """ + # If args provided, show detail view for that command + if args.strip(): + await _show_command_detail(player, args.strip()) + return + + # Otherwise, show full list # Collect unique commands by CommandDefinition id (avoids alias duplication) seen: set[int] = set() unique_commands: list[CommandDefinition] = [] diff --git a/tests/test_commands_list.py b/tests/test_commands_list.py index 6ec627f..e2307ff 100644 --- a/tests/test_commands_list.py +++ b/tests/test_commands_list.py @@ -1,5 +1,6 @@ """Tests for the commands listing command.""" +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest @@ -43,6 +44,23 @@ def player(mock_reader, mock_writer): ) +@pytest.fixture +def combat_moves(): + """Load and register combat moves from content directory.""" + from mudlib.combat.commands import register_combat_commands + + combat_dir = Path(__file__).resolve().parents[1] / "content" / "combat" + register_combat_commands(combat_dir) + + from mudlib.combat import commands as combat_cmds + + yield combat_cmds.combat_moves + + # Clean up + combat_cmds.combat_moves = {} + # Note: commands stay registered in _registry, but that's ok for tests + + @pytest.mark.asyncio async def test_commands_command_exists(): """Test that commands command is registered.""" @@ -142,3 +160,96 @@ async def test_commands_via_alias(player): # Should produce the same output structure assert "Movement:" in output assert "Other:" in output + + +@pytest.mark.asyncio +async def test_commands_detail_regular_command(player): + """Test commands detail view for a regular command.""" + await commands.dispatch(player, "commands look") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "look" in output + assert "aliases: l" in output + assert "mode: normal" in output + + +@pytest.mark.asyncio +async def test_commands_detail_simple_combat_move(player, combat_moves): + """Test commands detail view for a simple combat move.""" + await commands.dispatch(player, "commands roundhouse") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "roundhouse" in output + assert "aliases: rh" in output + assert "type: attack" in output + assert "stamina: 8.0" in output + assert "timing window: 2000ms" in output + assert "damage: 25%" in output + assert "{attacker} spins into a roundhouse kick!" in output + assert "countered by: duck, parry high, parry low" in output + + +@pytest.mark.asyncio +async def test_commands_detail_variant_base(player, combat_moves): + """Test commands detail view for a variant base command.""" + await commands.dispatch(player, "commands punch") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "punch" in output + assert "type: attack" in output + + # Should show both variants + assert "punch left" in output + assert "aliases: pl" in output + assert "{attacker} winds up a left hook!" in output + assert "countered by: dodge right, parry high" in output + + assert "punch right" in output + assert "aliases: pr" in output + assert "{attacker} winds up a right hook!" in output + assert "countered by: dodge left, parry high" in output + + # Should show shared properties in each variant + assert "stamina: 5.0" in output + assert "timing window: 1800ms" in output + assert "damage: 15%" in output + + +@pytest.mark.asyncio +async def test_commands_detail_specific_variant(player, combat_moves): + """Test commands detail view for a specific variant.""" + await commands.dispatch(player, "commands punch left") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "punch left" in output + assert "aliases: pl" in output + assert "type: attack" in output + assert "stamina: 5.0" in output + assert "timing window: 1800ms" in output + assert "damage: 15%" in output + assert "{attacker} winds up a left hook!" in output + assert "countered by: dodge right, parry high" in output + + # Should NOT show "punch right" + assert "punch right" not in output + + +@pytest.mark.asyncio +async def test_commands_detail_unknown_command(player): + """Test commands detail view for an unknown command.""" + await commands.dispatch(player, "commands nonexistent") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + assert "Unknown command: nonexistent" in output + + +@pytest.mark.asyncio +async def test_commands_detail_via_alias(player, combat_moves): + """Test commands detail view via an alias.""" + await commands.dispatch(player, "commands rh") + output = "".join([call[0][0] for call in player.writer.write.call_args_list]) + + # Should show the full command details, not the alias + assert "roundhouse" in output + assert "aliases: rh" in output + assert "type: attack" in output