diff --git a/src/mudlib/object.py b/src/mudlib/object.py index 79f54f1..aff45c5 100644 --- a/src/mudlib/object.py +++ b/src/mudlib/object.py @@ -2,6 +2,7 @@ from __future__ import annotations +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field @@ -22,11 +23,24 @@ class Object: _contents: list[Object] = field( default_factory=list, init=False, repr=False, compare=False ) + _verbs: dict[str, Callable[..., Awaitable[None]]] = field( + default_factory=dict, init=False, repr=False, compare=False + ) def __post_init__(self) -> None: if self.location is not None: self.location._contents.append(self) + # Auto-register verbs from @verb decorated methods + for attr_name in dir(self): + # Skip private/magic attributes + if attr_name.startswith("_"): + continue + attr = getattr(self, attr_name) + # Check if this is a verb-decorated method + if hasattr(attr, "_verb_name"): + self.register_verb(attr._verb_name, attr) + @property def contents(self) -> list[Object]: """Everything whose location is this object.""" @@ -61,3 +75,17 @@ class Object: def can_accept(self, obj: Object) -> bool: """Whether this object accepts obj as contents. Default: no.""" return False + + def register_verb( + self, name: str, handler: Callable[..., Awaitable[None]] + ) -> None: + """Register a verb handler on this object.""" + self._verbs[name] = handler + + def get_verb(self, name: str) -> Callable[..., Awaitable[None]] | None: + """Get a verb handler by name, or None if not found.""" + return self._verbs.get(name) + + def has_verb(self, name: str) -> bool: + """Check if this object has a verb registered.""" + return name in self._verbs diff --git a/src/mudlib/verbs.py b/src/mudlib/verbs.py new file mode 100644 index 0000000..169283d --- /dev/null +++ b/src/mudlib/verbs.py @@ -0,0 +1,62 @@ +"""Verb system for interactive objects.""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from mudlib.object import Object + from mudlib.player import Player + + +def verb(name: str) -> Callable: + """Decorator to mark a method as a verb handler. + + Usage: + class Fountain(Thing): + @verb("drink") + async def drink(self, player, args): + await player.send("You drink from the fountain.\\r\\n") + """ + + def decorator(func: Callable) -> Callable: + func._verb_name = name # type: ignore + return func + + return decorator + + +def find_object(name: str, player: Player) -> Object | None: + """Find an object by name or alias. + + Searches inventory first, then ground at player position. + Works on all Objects that have a name and optional aliases attribute. + """ + name_lower = name.lower() + + # Search inventory first + for obj in player.contents: + if obj.name.lower() == name_lower: + return obj + # Check aliases if the object has them + aliases = getattr(obj, "aliases", None) + if aliases and name_lower in (a.lower() for a in aliases): + return obj + + # Search ground at player position + if player.location is not None: + from mudlib.zone import Zone + + if isinstance(player.location, Zone): + for obj in player.location.contents_at(player.x, player.y): + if obj is player: + continue + if obj.name.lower() == name_lower: + return obj + # Check aliases if the object has them + aliases = getattr(obj, "aliases", None) + if aliases and name_lower in (a.lower() for a in aliases): + return obj + + return None diff --git a/tests/test_verbs.py b/tests/test_verbs.py new file mode 100644 index 0000000..800157d --- /dev/null +++ b/tests/test_verbs.py @@ -0,0 +1,243 @@ +"""Tests for the verb system on Object.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.object import Object +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.verbs import find_object, 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 test_object_starts_with_empty_verbs(): + """Object starts with empty _verbs dict.""" + obj = Object(name="rock") + assert obj._verbs == {} + + +def test_register_verb(): + """register_verb adds a verb to the _verbs dict.""" + + async def test_handler(obj, player, args): + pass + + obj = Object(name="fountain") + obj.register_verb("drink", test_handler) + assert "drink" in obj._verbs + assert obj._verbs["drink"] is test_handler + + +def test_get_verb_returns_handler(): + """get_verb returns the registered handler.""" + + async def test_handler(obj, player, args): + pass + + obj = Object(name="fountain") + obj.register_verb("drink", test_handler) + assert obj.get_verb("drink") is test_handler + + +def test_get_verb_returns_none_for_unknown(): + """get_verb returns None for unknown verb.""" + obj = Object(name="rock") + assert obj.get_verb("nonexistent") is None + + +def test_has_verb_returns_true_when_present(): + """has_verb returns True when verb is registered.""" + + async def test_handler(obj, player, args): + pass + + obj = Object(name="fountain") + obj.register_verb("drink", test_handler) + assert obj.has_verb("drink") is True + + +def test_has_verb_returns_false_when_absent(): + """has_verb returns False when verb is not registered.""" + obj = Object(name="rock") + assert obj.has_verb("drink") is False + + +def test_verb_decorator_marks_method(): + """@verb decorator marks method with _verb_name attribute.""" + + class TestObject(Object): + @verb("test") + async def test_verb(self, player, args): + pass + + assert hasattr(TestObject.test_verb, "_verb_name") + assert TestObject.test_verb._verb_name == "test" + + +def test_verb_decorator_auto_registers_on_instantiation(): + """Class with @verb methods auto-registers verbs on instantiation.""" + + class Fountain(Thing): + @verb("drink") + async def drink(self, player, args): + pass + + fountain = Fountain(name="fountain") + assert fountain.has_verb("drink") + assert fountain.get_verb("drink") is not None + + +def test_multiple_verbs_on_same_class(): + """Multiple verbs on same class all register.""" + + class Fountain(Thing): + @verb("drink") + async def drink(self, player, args): + pass + + @verb("splash") + async def splash(self, player, args): + pass + + fountain = Fountain(name="fountain") + assert fountain.has_verb("drink") + assert fountain.has_verb("splash") + + +@pytest.mark.asyncio +async def test_verb_handler_can_be_called(player): + """Verb handler can be called as an async function.""" + called = False + received_args = None + + class Fountain(Thing): + @verb("drink") + async def drink(self, player, args): + nonlocal called, received_args + called = True + received_args = args + + fountain = Fountain(name="fountain") + handler = fountain.get_verb("drink") + assert handler is not None + # handler is a bound method, so we don't pass self/fountain again + await handler(player, "from the left side") + + assert called + assert received_args == "from the left side" + + +def test_verb_registered_on_parent_class_propagates_to_subclass(): + """Verb registered on parent class propagates to subclass instances.""" + + class BaseFountain(Thing): + @verb("drink") + async def drink(self, player, args): + pass + + class FancyFountain(BaseFountain): + @verb("splash") + async def splash(self, player, args): + pass + + fancy = FancyFountain(name="fancy fountain") + assert fancy.has_verb("drink") + assert fancy.has_verb("splash") + + +def test_find_object_by_name_in_inventory(player): + """find_object finds object by name in inventory.""" + sword = Thing(name="sword", location=player) + found = find_object("sword", player) + assert found is sword + + +def test_find_object_by_alias_in_inventory(player): + """find_object finds object by alias in inventory.""" + can = Thing(name="pepsi can", aliases=["can", "pepsi"], location=player) + found = find_object("can", player) + assert found is can + + +def test_find_object_by_name_on_ground(player, test_zone): + """find_object finds object by name on ground.""" + rock = Thing(name="rock", location=test_zone, x=5, y=5) + found = find_object("rock", player) + assert found is rock + + +def test_find_object_by_alias_on_ground(player, test_zone): + """find_object finds object by alias on ground.""" + can = Thing( + name="pepsi can", aliases=["can", "pepsi"], location=test_zone, x=5, y=5 + ) + found = find_object("pepsi", player) + assert found is can + + +def test_find_object_prefers_inventory_over_ground(player, test_zone): + """find_object prefers inventory over ground when matching object in both.""" + Thing(name="sword", location=test_zone, x=5, y=5) + inv_sword = Thing(name="sword", location=player) + found = find_object("sword", player) + assert found is inv_sword + + +def test_find_object_returns_none_when_nothing_matches(player): + """find_object returns None when nothing matches.""" + found = find_object("nonexistent", player) + assert found is None + + +def test_find_object_searches_all_objects_not_just_things(player, test_zone): + """find_object searches all Objects, not just Things.""" + + class CustomObject(Object): + def __init__(self, name, aliases=None, **kwargs): + super().__init__(name=name, **kwargs) + self.aliases = aliases or [] + + custom = CustomObject(name="widget", aliases=["gadget"], location=player) + found = find_object("widget", player) + assert found is custom + found_by_alias = find_object("gadget", player) + assert found_by_alias is custom + + +def test_find_object_skips_self(player, test_zone): + """find_object should not return the querying player.""" + player.move_to(test_zone, x=0, y=0) + result = find_object(player.name, player) + assert result is None