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:
parent
9534df8f9c
commit
d2de6bdc16
5 changed files with 434 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
40
src/mudlib/commands/use.py
Normal file
40
src/mudlib/commands/use.py
Normal 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="*"))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
180
tests/test_use.py
Normal file
180
tests/test_use.py
Normal 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
195
tests/test_verb_dispatch.py
Normal 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")
|
||||
Loading…
Reference in a new issue