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.""" """Thing template loading, registry, and spawning."""
import functools
import importlib
import logging
import tomllib import tomllib
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
@ -8,6 +12,8 @@ from mudlib.container import Container
from mudlib.object import Object from mudlib.object import Object
from mudlib.thing import Thing from mudlib.thing import Thing
logger = logging.getLogger(__name__)
@dataclass @dataclass
class ThingTemplate: class ThingTemplate:
@ -21,12 +27,25 @@ class ThingTemplate:
capacity: int | None = None capacity: int | None = None
closed: bool = False closed: bool = False
locked: bool = False locked: bool = False
# Verb handlers (verb_name -> module:function reference)
verbs: dict[str, str] = field(default_factory=dict)
# Module-level registry # Module-level registry
thing_templates: dict[str, ThingTemplate] = {} 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: def load_thing_template(path: Path) -> ThingTemplate:
"""Parse a thing TOML file into a ThingTemplate.""" """Parse a thing TOML file into a ThingTemplate."""
with open(path, "rb") as f: with open(path, "rb") as f:
@ -39,6 +58,7 @@ def load_thing_template(path: Path) -> ThingTemplate:
capacity=data.get("capacity"), capacity=data.get("capacity"),
closed=data.get("closed", False), closed=data.get("closed", False),
locked=data.get("locked", 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.""" """Create a Thing instance from a template at the given location."""
# If template has capacity, spawn a Container instead of a Thing # If template has capacity, spawn a Container instead of a Thing
if template.capacity is not None: if template.capacity is not None:
return Container( obj = Container(
name=template.name, name=template.name,
description=template.description, description=template.description,
portable=template.portable, portable=template.portable,
@ -73,13 +93,24 @@ def spawn_thing(
x=x, x=x,
y=y, 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( # Register verbs from the template
name=template.name, for verb_name, handler_ref in template.verbs.items():
description=template.description, handler = _resolve_handler(handler_ref)
portable=template.portable, if handler is None:
aliases=list(template.aliases), continue
location=location, # Bind the object as first argument using partial
x=x, bound_handler = functools.partial(handler, obj)
y=y, 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")