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:
parent
6ce57ad970
commit
e724abb926
3 changed files with 287 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Tests for mudlib."""
|
||||
245
tests/test_toml_verbs.py
Normal file
245
tests/test_toml_verbs.py
Normal 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")
|
||||
Loading…
Reference in a new issue