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.
This commit is contained in:
Jared Miller 2026-02-11 21:33:43 -05:00
parent 6ce57ad970
commit e724abb926
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 287 additions and 10 deletions

View file

@ -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,8 +93,8 @@ def spawn_thing(
x=x,
y=y,
)
return Thing(
else:
obj = Thing(
name=template.name,
description=template.description,
portable=template.portable,
@ -83,3 +103,14 @@ def spawn_thing(
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

1
tests/__init__.py Normal file
View file

@ -0,0 +1 @@
"""Tests for mudlib."""

245
tests/test_toml_verbs.py Normal file
View file

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