From 0f7404cb127d1b9330634023bae95b3e86eeba7d Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sun, 8 Feb 2026 14:41:40 -0500 Subject: [PATCH] Fix prefix matching for combat move variants The variant handler now supports prefix matching for directional variants. This allows 'pa hi' to match 'parry high', 'pa lo' to match 'parry low', etc. Implementation: - First tries exact match on variant key - Falls back to prefix matching if no exact match - Returns unique match if exactly one variant starts with the prefix - Shows disambiguation message if multiple variants match - Shows error with valid options if no variants match Tests cover exact match, prefix match, ambiguous prefix, no match, single-char prefix, case-insensitivity, and preservation of target args. --- src/mudlib/combat/commands.py | 23 +++- tests/test_variant_prefix.py | 213 ++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 tests/test_variant_prefix.py 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)