diff --git a/src/mudlib/commands/__init__.py b/src/mudlib/commands/__init__.py index 4f84bad..7508262 100644 --- a/src/mudlib/commands/__init__.py +++ b/src/mudlib/commands/__init__.py @@ -145,7 +145,24 @@ async def dispatch(player: Player, raw_input: str) -> None: result = resolve_prefix(command) if result is None: - # No matches + # No matches - try verb dispatch as fallback + if args: + from mudlib.verbs import find_object + + # Split args into target name and any extra arguments + target_parts = args.split(maxsplit=1) + target_name = target_parts[0] + extra_args = target_parts[1] if len(target_parts) > 1 else "" + + # Try to find the object + obj = find_object(target_name, player) + if obj is not None and obj.has_verb(command): + handler = obj.get_verb(command) + assert handler is not None # has_verb checked above + await handler(player, extra_args) + return + + # Still no match - show unknown command player.writer.write(f"Unknown command: {command}\r\n") await player.writer.drain() return diff --git a/src/mudlib/commands/use.py b/src/mudlib/commands/use.py new file mode 100644 index 0000000..93151dc --- /dev/null +++ b/src/mudlib/commands/use.py @@ -0,0 +1,40 @@ +from mudlib.commands import CommandDefinition, register +from mudlib.player import Player +from mudlib.verbs import find_object + + +async def cmd_use(player: Player, args: str) -> None: + """Use an object, optionally on a target.""" + args = args.strip() + + if not args: + await player.send("Use what?\r\n") + return + + # Parse "use X on Y" syntax + if " on " in args: + parts = args.split(" on ", 1) + object_name = parts[0].strip() + target_args = parts[1].strip() + else: + object_name = args + target_args = "" + + # Find the object + obj = find_object(object_name, player) + if not obj: + await player.send("You don't see that here.\r\n") + return + + # Check if object has use verb + if not obj.has_verb("use"): + await player.send("You can't use that.\r\n") + return + + # Call the use verb + use_handler = obj.get_verb("use") + assert use_handler is not None # has_verb checked above + await use_handler(player, target_args) + + +register(CommandDefinition("use", cmd_use, mode="*")) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index e1bf713..529b78d 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -26,6 +26,7 @@ import mudlib.commands.quit import mudlib.commands.reload import mudlib.commands.spawn import mudlib.commands.things +import mudlib.commands.use from mudlib.caps import parse_mtts from mudlib.combat.commands import register_combat_commands from mudlib.combat.engine import process_combat diff --git a/tests/test_use.py b/tests/test_use.py new file mode 100644 index 0000000..5d9048a --- /dev/null +++ b/tests/test_use.py @@ -0,0 +1,180 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands.use import cmd_use +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.verbs import verb +from mudlib.zone import Zone + + +@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 test_zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + return Zone(name="testzone", width=10, height=10, terrain=terrain) + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): + return Player( + name="TestPlayer", + x=5, + y=5, + reader=mock_reader, + writer=mock_writer, + location=test_zone, + ) + + +def get_output(mock_writer): + """Extract all text written to the mock writer.""" + return "".join(call[0][0] for call in mock_writer.write.call_args_list) + + +@pytest.mark.asyncio +async def test_use_no_args(player, mock_writer): + """use with no args should prompt for what to use""" + await cmd_use(player, "") + output = get_output(mock_writer) + assert "Use what?" in output + + +@pytest.mark.asyncio +async def test_use_object_with_verb(player, mock_writer, test_zone): + """use rock should find rock with use verb and call it""" + + class Rock(Thing): + @verb("use") + async def use_verb(self, player, args): + await player.send("You use the rock.\r\n") + + _rock = Rock( + name="rock", description="a smooth rock", portable=True, location=test_zone + ) + _rock.x = 5 + _rock.y = 5 + + await cmd_use(player, "rock") + output = get_output(mock_writer) + assert "You use the rock." in output + + +@pytest.mark.asyncio +async def test_use_object_without_verb(player, mock_writer, test_zone): + """use rock when rock has no use verb should give error""" + _rock = Thing( + name="rock", description="a smooth rock", portable=True, location=test_zone + ) + _rock.x = 5 + _rock.y = 5 + + await cmd_use(player, "rock") + output = get_output(mock_writer) + assert "You can't use that." in output + + +@pytest.mark.asyncio +async def test_use_object_not_found(player, mock_writer): + """use flurb when nothing matches should give error""" + await cmd_use(player, "flurb") + output = get_output(mock_writer) + assert "You don't see that here." in output + + +@pytest.mark.asyncio +async def test_use_object_on_target(player, mock_writer, test_zone): + """use key on chest should pass chest as args to verb""" + + class Key(Thing): + @verb("use") + async def use_verb(self, player, args): + await player.send(f"You use the key on {args}.\r\n") + + _key = Key(name="key", description="a brass key", portable=True, location=test_zone) + _key.x = 5 + _key.y = 5 + + await cmd_use(player, "key on chest") + output = get_output(mock_writer) + assert "You use the key on chest." in output + + +@pytest.mark.asyncio +async def test_use_object_on_nonexistent_target(player, mock_writer, test_zone): + """use key on flurb should still work, passing flurb to verb handler""" + + class Key(Thing): + @verb("use") + async def use_verb(self, player, args): + await player.send(f"You use the key on {args}.\r\n") + + _key = Key(name="key", description="a brass key", portable=True, location=test_zone) + _key.x = 5 + _key.y = 5 + + await cmd_use(player, "key on flurb") + output = get_output(mock_writer) + assert "You use the key on flurb." in output + + +@pytest.mark.asyncio +async def test_use_object_in_inventory(player, mock_writer): + """use should find objects in inventory""" + + class Potion(Thing): + @verb("use") + async def use_verb(self, player, args): + await player.send("You drink the potion.\r\n") + + _potion = Potion( + name="potion", description="a healing potion", portable=True, location=player + ) + + await cmd_use(player, "potion") + output = get_output(mock_writer) + assert "You drink the potion." in output + + +@pytest.mark.asyncio +async def test_use_passes_correct_args(player, mock_writer, test_zone): + """verify verb handler receives correct args string""" + received_args = None + + class Tool(Thing): + @verb("use") + async def use_verb(self, player, args): + nonlocal received_args + received_args = args + await player.send(f"Used with args: '{args}'\r\n") + + _tool = Tool( + name="tool", description="a multi-tool", portable=True, location=test_zone + ) + _tool.x = 5 + _tool.y = 5 + + # Test with no target + await cmd_use(player, "tool") + assert received_args == "" + + # Reset writer + mock_writer.write.reset_mock() + received_args = None + + # Test with target + await cmd_use(player, "tool on something") + assert received_args == "something" diff --git a/tests/test_verb_dispatch.py b/tests/test_verb_dispatch.py new file mode 100644 index 0000000..53110e7 --- /dev/null +++ b/tests/test_verb_dispatch.py @@ -0,0 +1,195 @@ +"""Tests for verb dispatch fallback in command system.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands import dispatch +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.verbs import verb +from mudlib.zone import Zone + + +@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 test_zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + return Zone(name="testzone", width=10, height=10, terrain=terrain) + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): + return Player( + name="TestPlayer", + x=5, + y=5, + reader=mock_reader, + writer=mock_writer, + location=test_zone, + ) + + +class Fountain(Thing): + """Test object with drink verb.""" + + @verb("drink") + async def drink(self, player, args): + await player.send("You drink from the fountain.\r\n") + + @verb("splash") + async def splash(self, player, args): + await player.send(f"You splash the fountain{' ' + args if args else ''}.\r\n") + + +@pytest.mark.asyncio +async def test_verb_dispatch_simple(player, test_zone): + """Test 'drink fountain' dispatches to fountain's drink verb.""" + _fountain = Fountain( + name="fountain", + description="a stone fountain", + portable=False, + location=test_zone, + x=5, + y=5, + ) + + await dispatch(player, "drink fountain") + + player.writer.write.assert_called_once_with("You drink from the fountain.\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_with_extra_args(player, test_zone): + """Test 'splash fountain hard' passes 'hard' as extra args.""" + _fountain = Fountain( + name="fountain", + description="a stone fountain", + portable=False, + location=test_zone, + x=5, + y=5, + ) + + await dispatch(player, "splash fountain hard") + + player.writer.write.assert_called_once_with("You splash the fountain hard.\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_object_lacks_verb(player, test_zone): + """Test 'drink rock' fails if rock doesn't have drink verb.""" + _rock = Thing( + name="rock", + description="a plain rock", + portable=True, + location=test_zone, + x=5, + y=5, + ) + + await dispatch(player, "drink rock") + + player.writer.write.assert_called_once_with("Unknown command: drink\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_object_not_found(player, test_zone): + """Test 'drink flurb' fails if flurb doesn't exist.""" + await dispatch(player, "drink flurb") + + player.writer.write.assert_called_once_with("Unknown command: drink\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_no_args(player, test_zone): + """Test 'drink' with no args doesn't try verb dispatch.""" + await dispatch(player, "drink") + + player.writer.write.assert_called_once_with("Unknown command: drink\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_doesnt_intercept_real_commands(player, test_zone): + """Test that verb dispatch doesn't interfere with registered commands.""" + from mudlib.commands import CommandDefinition, register, unregister + + called = False + + async def test_command(player, args): + nonlocal called + called = True + await player.send("Real command executed.\r\n") + + register(CommandDefinition("test", test_command)) + try: + await dispatch(player, "test something") + assert called + player.writer.write.assert_called_once_with("Real command executed.\r\n") + finally: + unregister("test") + + +@pytest.mark.asyncio +async def test_verb_dispatch_finds_inventory_object(player, test_zone): + """Test verb dispatch finds object in inventory.""" + + class Potion(Thing): + @verb("drink") + async def drink(self, player, args): + await player.send("You drink the potion.\r\n") + + _potion = Potion( + name="potion", + description="a health potion", + portable=True, + location=player, + ) + + await dispatch(player, "drink potion") + + player.writer.write.assert_called_once_with("You drink the potion.\r\n") + player.writer.drain.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_verb_dispatch_doesnt_fire_on_ambiguous_command(player, test_zone): + """Test verb dispatch doesn't fire when command is ambiguous.""" + from mudlib.commands import CommandDefinition, register, unregister + + async def test_one(player, args): + await player.send("test one\r\n") + + async def test_two(player, args): + await player.send("test two\r\n") + + register(CommandDefinition("testone", test_one)) + register(CommandDefinition("testtwo", test_two)) + try: + # "test" matches both testone and testtwo + await dispatch(player, "test something") + + # Should get ambiguous command message, not verb dispatch + written_text = player.writer.write.call_args[0][0].lower() + # The ambiguous message is "testone or testtwo?" not "ambiguous" + assert "or" in written_text and "?" in written_text + finally: + unregister("testone") + unregister("testtwo")