From e724abb92662f03420c36415ebf361b7f9bcb1ce Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 21:33:43 -0500 Subject: [PATCH] Add TOML verb support for thing templates Thing templates can now define verbs in TOML using [verbs] section with module:function references. Verbs are resolved at spawn time and bound to the spawned object instance using functools.partial. Works for both Thing and Container instances. --- src/mudlib/things.py | 51 ++++++-- tests/__init__.py | 1 + tests/test_toml_verbs.py | 245 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 tests/__init__.py create mode 100644 tests/test_toml_verbs.py diff --git a/src/mudlib/things.py b/src/mudlib/things.py index c9da055..2d7f9ec 100644 --- a/src/mudlib/things.py +++ b/src/mudlib/things.py @@ -1,6 +1,10 @@ """Thing template loading, registry, and spawning.""" +import functools +import importlib +import logging import tomllib +from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from pathlib import Path @@ -8,6 +12,8 @@ from mudlib.container import Container from mudlib.object import Object from mudlib.thing import Thing +logger = logging.getLogger(__name__) + @dataclass class ThingTemplate: @@ -21,12 +27,25 @@ class ThingTemplate: capacity: int | None = None closed: bool = False locked: bool = False + # Verb handlers (verb_name -> module:function reference) + verbs: dict[str, str] = field(default_factory=dict) # Module-level registry thing_templates: dict[str, ThingTemplate] = {} +def _resolve_handler(ref: str) -> Callable[..., Awaitable[None]] | None: + """Resolve a 'module:function' reference to a callable.""" + module_path, _, func_name = ref.rpartition(":") + try: + module = importlib.import_module(module_path) + return getattr(module, func_name) + except (ImportError, AttributeError) as e: + logger.warning("Failed to resolve verb handler '%s': %s", ref, e) + return None + + def load_thing_template(path: Path) -> ThingTemplate: """Parse a thing TOML file into a ThingTemplate.""" with open(path, "rb") as f: @@ -39,6 +58,7 @@ def load_thing_template(path: Path) -> ThingTemplate: capacity=data.get("capacity"), closed=data.get("closed", False), locked=data.get("locked", False), + verbs=data.get("verbs", {}), ) @@ -61,7 +81,7 @@ def spawn_thing( """Create a Thing instance from a template at the given location.""" # If template has capacity, spawn a Container instead of a Thing if template.capacity is not None: - return Container( + obj = Container( name=template.name, description=template.description, portable=template.portable, @@ -73,13 +93,24 @@ def spawn_thing( x=x, y=y, ) + else: + obj = Thing( + name=template.name, + description=template.description, + portable=template.portable, + aliases=list(template.aliases), + location=location, + x=x, + y=y, + ) - return Thing( - name=template.name, - description=template.description, - portable=template.portable, - aliases=list(template.aliases), - location=location, - x=x, - y=y, - ) + # Register verbs from the template + for verb_name, handler_ref in template.verbs.items(): + handler = _resolve_handler(handler_ref) + if handler is None: + continue + # Bind the object as first argument using partial + bound_handler = functools.partial(handler, obj) + obj.register_verb(verb_name, bound_handler) + + return obj diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..795bde7 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for mudlib.""" diff --git a/tests/test_toml_verbs.py b/tests/test_toml_verbs.py new file mode 100644 index 0000000..762e0b1 --- /dev/null +++ b/tests/test_toml_verbs.py @@ -0,0 +1,245 @@ +"""Tests for TOML verb support in thing templates.""" + +import textwrap +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.player import Player +from mudlib.things import ( + Container, + ThingTemplate, + load_thing_template, + spawn_thing, +) +from mudlib.zone import Zone + + +# Test handler functions +async def _test_verb_handler(obj, player, args): + """Test verb handler that sends a message.""" + await player.send(f"verb on {obj.name} with args={args}\r\n") + + +async def _another_test_handler(obj, player, args): + """Another test handler.""" + await player.send(f"another verb on {obj.name}\r\n") + + +@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_thing_template_default_empty_verbs(): + """ThingTemplate has empty verbs dict by default.""" + template = ThingTemplate(name="test", description="a test") + assert template.verbs == {} + + +def test_load_template_with_verbs(tmp_path): + """load_thing_template parses [verbs] section from TOML.""" + toml = tmp_path / "key.toml" + toml.write_text( + textwrap.dedent("""\ + name = "key" + description = "a brass key" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + """) + ) + template = load_thing_template(toml) + assert template.verbs == {"unlock": "tests.test_toml_verbs:_test_verb_handler"} + + +def test_load_template_without_verbs_section(tmp_path): + """load_thing_template works without [verbs] section (backwards compatible).""" + toml = tmp_path / "rock.toml" + toml.write_text( + textwrap.dedent("""\ + name = "rock" + description = "a smooth stone" + portable = true + """) + ) + template = load_thing_template(toml) + assert template.verbs == {} + + +def test_load_template_with_multiple_verbs(tmp_path): + """load_thing_template handles multiple verbs.""" + toml = tmp_path / "multi.toml" + toml.write_text( + textwrap.dedent("""\ + name = "door" + description = "a wooden door" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + open = "tests.test_toml_verbs:_another_test_handler" + """) + ) + template = load_thing_template(toml) + assert len(template.verbs) == 2 + assert "unlock" in template.verbs + assert "open" in template.verbs + + +def test_spawn_thing_registers_verbs(test_zone, tmp_path): + """spawn_thing registers verbs on spawned instance.""" + toml = tmp_path / "key.toml" + toml.write_text( + textwrap.dedent("""\ + name = "key" + description = "a brass key" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + """) + ) + template = load_thing_template(toml) + thing = spawn_thing(template, test_zone, x=5, y=5) + + assert thing.has_verb("unlock") + + +def test_spawn_container_registers_verbs(test_zone, tmp_path): + """spawn_thing registers verbs on Container instances too.""" + toml = tmp_path / "chest.toml" + toml.write_text( + textwrap.dedent("""\ + name = "chest" + description = "a wooden chest" + capacity = 10 + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + """) + ) + template = load_thing_template(toml) + container = spawn_thing(template, test_zone, x=5, y=5) + + assert isinstance(container, Container) + assert container.has_verb("unlock") + + +@pytest.mark.asyncio +async def test_spawned_verb_handler_receives_correct_args(test_zone, player, tmp_path): + """Spawned thing's verb handler can be called and receives (player, args).""" + toml = tmp_path / "key.toml" + toml.write_text( + textwrap.dedent("""\ + name = "key" + description = "a brass key" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + """) + ) + template = load_thing_template(toml) + thing = spawn_thing(template, test_zone, x=5, y=5) + + # Call the verb handler + handler = thing.get_verb("unlock") + assert handler is not None + await handler(player, "door") + + # Check that the message was sent + player.writer.write.assert_called() + call_args = player.writer.write.call_args[0][0] + assert "verb on key with args=door" in call_args + + +@pytest.mark.asyncio +async def test_verb_handler_receives_correct_object_instance( + test_zone, player, tmp_path +): + """Handler receives the correct object instance as first arg (via partial).""" + toml = tmp_path / "key.toml" + toml.write_text( + textwrap.dedent("""\ + name = "brass key" + description = "a brass key" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + """) + ) + template = load_thing_template(toml) + thing = spawn_thing(template, test_zone, x=5, y=5) + + # Call the verb handler + handler = thing.get_verb("unlock") + assert handler is not None + await handler(player, "chest") + + # Check that the object name appears in the message (verifying correct object) + player.writer.write.assert_called() + call_args = player.writer.write.call_args[0][0] + assert "verb on brass key with args=chest" in call_args + + +@pytest.mark.asyncio +async def test_multiple_verbs_all_registered(test_zone, player, tmp_path): + """Multiple verbs in TOML all get registered.""" + toml = tmp_path / "door.toml" + toml.write_text( + textwrap.dedent("""\ + name = "door" + description = "a wooden door" + [verbs] + unlock = "tests.test_toml_verbs:_test_verb_handler" + open = "tests.test_toml_verbs:_another_test_handler" + """) + ) + template = load_thing_template(toml) + thing = spawn_thing(template, test_zone, x=5, y=5) + + # Both verbs should be registered + assert thing.has_verb("unlock") + assert thing.has_verb("open") + + # Both should work + unlock_handler = thing.get_verb("unlock") + assert unlock_handler is not None + await unlock_handler(player, "test1") + player.writer.write.assert_called() + + open_handler = thing.get_verb("open") + assert open_handler is not None + await open_handler(player, "test2") + call_args = player.writer.write.call_args[0][0] + assert "another verb on door" in call_args + + +def test_bad_handler_ref_skipped(test_zone): + """A TOML verb with bad handler ref is skipped, not a crash.""" + template = ThingTemplate( + name="bad", + description="oops", + portable=True, + verbs={"zap": "nonexistent.module:fake_handler"}, + ) + thing = spawn_thing(template, test_zone, x=5, y=5) + assert not thing.has_verb("zap")