diff --git a/src/mudlib/combat/commands.py b/src/mudlib/combat/commands.py index de9d26c..db212a8 100644 --- a/src/mudlib/combat/commands.py +++ b/src/mudlib/combat/commands.py @@ -151,13 +151,26 @@ def make_variant_handler( return variant_key = parts[0].lower() + + # Try exact match first move = variant_moves.get(variant_key) if move is None: - variants = "/".join(variant_moves.keys()) - await player.send( - f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n" - ) - return + # Fall back to prefix matching + matches = [k for k in variant_moves if k.startswith(variant_key)] + if len(matches) == 1: + move = variant_moves[matches[0]] + elif len(matches) > 1: + variants = "/".join(sorted(matches)) + await player.send( + f"Ambiguous {base_name} direction: {variant_key}. ({variants})\r\n" + ) + return + else: + variants = "/".join(variant_moves.keys()) + await player.send( + f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n" + ) + return target_args = parts[1] if len(parts) > 1 else "" await handler_fn(player, target_args, move) diff --git a/tests/test_variant_prefix.py b/tests/test_variant_prefix.py new file mode 100644 index 0000000..1ad890a --- /dev/null +++ b/tests/test_variant_prefix.py @@ -0,0 +1,213 @@ +"""Tests for variant prefix matching in combat commands.""" + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import mudlib.commands.movement as movement_mod +from mudlib.combat import commands as combat_commands +from mudlib.combat.engine import active_encounters +from mudlib.combat.moves import load_moves +from mudlib.player import Player, players + + +@pytest.fixture(autouse=True) +def clear_state(): + """Clear encounters and players before and after each test.""" + active_encounters.clear() + players.clear() + yield + active_encounters.clear() + players.clear() + + +@pytest.fixture(autouse=True) +def mock_world(): + """Inject a mock world for send_nearby_message.""" + fake_world = MagicMock() + fake_world.width = 256 + fake_world.height = 256 + old = movement_mod.world + movement_mod.world = fake_world + yield fake_world + movement_mod.world = old + + +@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): + p = Player(name="Goku", x=0, y=0, reader=mock_reader, writer=mock_writer) + players[p.name] = p + return p + + +@pytest.fixture +def target(mock_reader, mock_writer): + t = Player(name="Vegeta", x=0, y=0, reader=mock_reader, writer=mock_writer) + players[t.name] = t + return t + + +@pytest.fixture +def moves(): + """Load combat moves from content directory.""" + content_dir = Path(__file__).parent.parent / "content" / "combat" + return load_moves(content_dir) + + +# --- variant prefix matching tests --- + + +@pytest.mark.asyncio +async def test_exact_match_still_works(player, target, moves): + """Test that exact variant names still work (e.g., 'high' matches 'high').""" + variant_moves = { + "high": moves["parry high"], + "low": moves["parry low"], + } + handler = combat_commands.make_variant_handler( + "parry", variant_moves, combat_commands.do_defend + ) + + await handler(player, "high") + + # Should succeed without error + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert not any("unknown" in msg.lower() for msg in messages) + assert not any("ambiguous" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_prefix_match_works(player, target, moves): + """Test that prefix matching resolves unique prefixes (e.g., 'hi' -> 'high').""" + variant_moves = { + "high": moves["parry high"], + "low": moves["parry low"], + } + handler = combat_commands.make_variant_handler( + "parry", variant_moves, combat_commands.do_defend + ) + + # Test "hi" -> "high" + player.writer.write.reset_mock() + await handler(player, "hi") + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert not any("unknown" in msg.lower() for msg in messages) + assert not any("ambiguous" in msg.lower() for msg in messages) + + # Test "lo" -> "low" + player.writer.write.reset_mock() + await handler(player, "lo") + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert not any("unknown" in msg.lower() for msg in messages) + assert not any("ambiguous" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_ambiguous_prefix_shows_disambiguation(player, moves): + """Test that ambiguous prefix shows options (e.g., 'l' for 'left'/'long').""" + # Create hypothetical moves with conflicting prefixes + variant_moves = { + "left": moves["punch left"], + "long": moves["punch left"], # reuse same move, just for testing keys + } + handler = combat_commands.make_variant_handler( + "punch", variant_moves, combat_commands.do_attack + ) + + await handler(player, "l") + + messages = [call[0][0] for call in player.writer.write.call_args_list] + output = "".join(messages).lower() + assert "ambiguous" in output + assert "left" in output + assert "long" in output + + +@pytest.mark.asyncio +async def test_no_match_shows_error(player, moves): + """Test that no matching variant shows error with valid options.""" + variant_moves = { + "high": moves["parry high"], + "low": moves["parry low"], + } + handler = combat_commands.make_variant_handler( + "parry", variant_moves, combat_commands.do_defend + ) + + await handler(player, "middle") + + messages = [call[0][0] for call in player.writer.write.call_args_list] + output = "".join(messages).lower() + assert "unknown" in output + assert "high" in output or "low" in output + + +@pytest.mark.asyncio +async def test_prefix_with_target_args(player, target, moves): + """Test that prefix matching preserves target arguments.""" + variant_moves = { + "left": moves["punch left"], + "right": moves["punch right"], + } + handler = combat_commands.make_variant_handler( + "punch", variant_moves, combat_commands.do_attack + ) + + await handler(player, "le Vegeta") + + # Should start combat with target + from mudlib.combat.engine import get_encounter + + encounter = get_encounter(player) + assert encounter is not None + assert encounter.defender is target + + +@pytest.mark.asyncio +async def test_single_char_prefix_match(player, moves): + """Test that single-char prefix works when unambiguous (e.g., 'h' -> 'high').""" + variant_moves = { + "high": moves["parry high"], + "low": moves["parry low"], + } + handler = combat_commands.make_variant_handler( + "parry", variant_moves, combat_commands.do_defend + ) + + await handler(player, "h") + + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert not any("unknown" in msg.lower() for msg in messages) + assert not any("ambiguous" in msg.lower() for msg in messages) + + +@pytest.mark.asyncio +async def test_case_insensitive_prefix_match(player, moves): + """Test that prefix matching is case-insensitive.""" + variant_moves = { + "high": moves["parry high"], + "low": moves["parry low"], + } + handler = combat_commands.make_variant_handler( + "parry", variant_moves, combat_commands.do_defend + ) + + await handler(player, "HI") + + messages = [call[0][0] for call in player.writer.write.call_args_list] + assert not any("unknown" in msg.lower() for msg in messages) + assert not any("ambiguous" in msg.lower() for msg in messages)