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