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.
This commit is contained in:
Jared Miller 2026-02-08 14:41:40 -05:00
parent 67781578a3
commit 0f7404cb12
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
2 changed files with 231 additions and 5 deletions

View file

@ -151,8 +151,21 @@ def make_variant_handler(
return return
variant_key = parts[0].lower() variant_key = parts[0].lower()
# Try exact match first
move = variant_moves.get(variant_key) move = variant_moves.get(variant_key)
if move is None: if move is None:
# 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()) variants = "/".join(variant_moves.keys())
await player.send( await player.send(
f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n" f"Unknown {base_name} direction: {variant_key}. Try: {variants}\r\n"

View file

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