Add verb infrastructure on Object
Verbs let any Object have interactive handlers players can trigger. Uses @verb decorator to mark methods that auto-register on instantiation. - Object._verbs dict stores verb name to async handler mapping - Object.register_verb(), get_verb(), has_verb() API - @verb decorator marks methods with _verb_name attribute - __post_init__ scans for decorated methods and registers them - find_object() helper searches inventory then ground by name/alias - Bound methods stored in _verbs (self already bound) - Works on Object and all subclasses (Thing, Entity, etc) - 18 tests covering registration, lookup, decoration, inheritance
This commit is contained in:
parent
4ec09cffda
commit
fcfa13c785
3 changed files with 333 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
62
src/mudlib/verbs.py
Normal file
62
src/mudlib/verbs.py
Normal file
|
|
@ -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
|
||||
243
tests/test_verbs.py
Normal file
243
tests/test_verbs.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue