Add use command for verb-based interaction

Implements a TDD-built 'use' command that lets players invoke
object verbs with optional targets:
- use X - calls X's use verb
- use X on Y - calls X's use verb with Y as args
- Proper error messages for missing objects/verbs
- Tests cover all edge cases including inventory/ground search

Also fixes type checking issue in verb dispatch where get_verb
could return None.
This commit is contained in:
Jared Miller 2026-02-11 21:27:40 -05:00
parent 9534df8f9c
commit d2de6bdc16
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
5 changed files with 434 additions and 1 deletions

View file

@ -145,7 +145,24 @@ async def dispatch(player: Player, raw_input: str) -> None:
result = resolve_prefix(command) result = resolve_prefix(command)
if result is None: 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") player.writer.write(f"Unknown command: {command}\r\n")
await player.writer.drain() await player.writer.drain()
return return

View file

@ -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="*"))

View file

@ -26,6 +26,7 @@ import mudlib.commands.quit
import mudlib.commands.reload import mudlib.commands.reload
import mudlib.commands.spawn import mudlib.commands.spawn
import mudlib.commands.things import mudlib.commands.things
import mudlib.commands.use
from mudlib.caps import parse_mtts from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat

180
tests/test_use.py Normal file
View file

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

195
tests/test_verb_dispatch.py Normal file
View file

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