Compare commits

...

6 commits

Author SHA1 Message Date
e724abb926
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.
2026-02-11 21:47:33 -05:00
6ce57ad970
Add key-based unlock as first verb interaction
Implements unlock_handler that checks for a key in player inventory
and unlocks containers. Tests cover error cases (non-container,
not locked, no key), success case, key aliasing, and state preservation.
2026-02-11 21:47:33 -05:00
d2de6bdc16
Add use command for verb-based interaction
Implements a TDD-built 'use' command that lets players invoke
object verbs with optional targets:
- use X - calls X's use verb
- use X on Y - calls X's use verb with Y as args
- Proper error messages for missing objects/verbs
- Tests cover all edge cases including inventory/ground search

Also fixes type checking issue in verb dispatch where get_verb
could return None.
2026-02-11 21:47:33 -05:00
9534df8f9c
Add examine command for object inspection
Implements a global examine/ex command that shows detailed descriptions
of objects. Searches inventory first, then ground at player position.
Works with Things, Containers, and Mobs.
2026-02-11 21:47:33 -05:00
fcfa13c785
Add verb infrastructure on Object
Verbs let any Object have interactive handlers players can trigger.
Uses @verb decorator to mark methods that auto-register on instantiation.

- Object._verbs dict stores verb name to async handler mapping
- Object.register_verb(), get_verb(), has_verb() API
- @verb decorator marks methods with _verb_name attribute
- __post_init__ scans for decorated methods and registers them
- find_object() helper searches inventory then ground by name/alias
- Bound methods stored in _verbs (self already bound)
- Works on Object and all subclasses (Thing, Entity, etc)
- 18 tests covering registration, lookup, decoration, inheritance
2026-02-11 21:47:33 -05:00
4ec09cffda
Improve doc discovery 2026-02-11 21:47:05 -05:00
17 changed files with 1582 additions and 12 deletions

View file

@ -27,6 +27,8 @@ Telnet MUD engine built on telnetlib3. Python 3.12+, managed with uv.
## Docs ## Docs
Consult `docs/index.rst` when starting unfamiliar work to find relevant docs.
Three categories in `docs/`. Plain text or rst, not markdown. Three categories in `docs/`. Plain text or rst, not markdown.
- `docs/how/` - how things work. write one when you build something non-obvious. - `docs/how/` - how things work. write one when you build something non-obvious.

View file

@ -133,7 +133,8 @@ portals:
depends on: phase 3 (portals and containers are Things) depends on: phase 3 (portals and containers are Things)
enables: multi-zone world, interior spaces, locked doors, bags, keys enables: multi-zone world, interior spaces, locked doors, bags, keys
refs: object-model.rst (Container, Portal sections), refs: object-model.rst (Container, Portal sections),
zones-and-building.rst (zone data format, hand-built zones) zones-and-building.rst (zone data format, hand-built zones),
persistence.txt (what's persisted vs runtime — container contents not yet)
phase 5: verbs + interaction phase 5: verbs + interaction

View file

@ -145,7 +145,24 @@ async def dispatch(player: Player, raw_input: str) -> None:
result = resolve_prefix(command) result = resolve_prefix(command)
if result is None: if result is None:
# No matches # No matches - try verb dispatch as fallback
if args:
from mudlib.verbs import find_object
# Split args into target name and any extra arguments
target_parts = args.split(maxsplit=1)
target_name = target_parts[0]
extra_args = target_parts[1] if len(target_parts) > 1 else ""
# Try to find the object
obj = find_object(target_name, player)
if obj is not None and obj.has_verb(command):
handler = obj.get_verb(command)
assert handler is not None # has_verb checked above
await handler(player, extra_args)
return
# Still no match - show unknown command
player.writer.write(f"Unknown command: {command}\r\n") player.writer.write(f"Unknown command: {command}\r\n")
await player.writer.drain() await player.writer.drain()
return return

View file

@ -0,0 +1,80 @@
"""Examine command for detailed object inspection."""
from mudlib.commands import CommandDefinition, register
from mudlib.entity import Entity
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
def _find_object_in_inventory(name: str, player: Player) -> Thing | Entity | None:
"""Find an object in player inventory by name or alias."""
name_lower = name.lower()
for obj in player.contents:
# Only examine Things and Entities
if not isinstance(obj, (Thing, Entity)):
continue
# Match by name
if obj.name.lower() == name_lower:
return obj
# Match by alias (Things have aliases)
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
return obj
return None
def _find_object_at_position(name: str, player: Player) -> Thing | Entity | None:
"""Find an object on the ground at player position by name or alias."""
zone = player.location
if zone is None or not isinstance(zone, Zone):
return None
name_lower = name.lower()
for obj in zone.contents_at(player.x, player.y):
# Only examine Things and Entities
if not isinstance(obj, (Thing, Entity)):
continue
# Match by name
if obj.name.lower() == name_lower:
return obj
# Match by alias (Things have aliases)
if isinstance(obj, Thing) and name_lower in (a.lower() for a in obj.aliases):
return obj
return None
async def cmd_examine(player: Player, args: str) -> None:
"""Examine an object in detail."""
if not args.strip():
await player.send("Examine what?\r\n")
return
target_name = args.strip()
# Search inventory first
found = _find_object_in_inventory(target_name, player)
# Then search ground
if not found:
found = _find_object_at_position(target_name, player)
# Not found anywhere
if not found:
await player.send("You don't see that here.\r\n")
return
# Show description (both Thing and Entity have description)
desc = getattr(found, "description", "")
if desc:
await player.send(f"{desc}\r\n")
else:
await player.send("You see nothing special.\r\n")
register(CommandDefinition("examine", cmd_examine, aliases=["ex"], mode="*"))

View file

@ -0,0 +1,40 @@
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
from mudlib.verbs import find_object
async def cmd_use(player: Player, args: str) -> None:
"""Use an object, optionally on a target."""
args = args.strip()
if not args:
await player.send("Use what?\r\n")
return
# Parse "use X on Y" syntax
if " on " in args:
parts = args.split(" on ", 1)
object_name = parts[0].strip()
target_args = parts[1].strip()
else:
object_name = args
target_args = ""
# Find the object
obj = find_object(object_name, player)
if not obj:
await player.send("You don't see that here.\r\n")
return
# Check if object has use verb
if not obj.has_verb("use"):
await player.send("You can't use that.\r\n")
return
# Call the use verb
use_handler = obj.get_verb("use")
assert use_handler is not None # has_verb checked above
await use_handler(player, target_args)
register(CommandDefinition("use", cmd_use, mode="*"))

View file

@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field from dataclasses import dataclass, field
@ -22,11 +23,24 @@ class Object:
_contents: list[Object] = field( _contents: list[Object] = field(
default_factory=list, init=False, repr=False, compare=False default_factory=list, init=False, repr=False, compare=False
) )
_verbs: dict[str, Callable[..., Awaitable[None]]] = field(
default_factory=dict, init=False, repr=False, compare=False
)
def __post_init__(self) -> None: def __post_init__(self) -> None:
if self.location is not None: if self.location is not None:
self.location._contents.append(self) self.location._contents.append(self)
# Auto-register verbs from @verb decorated methods
for attr_name in dir(self):
# Skip private/magic attributes
if attr_name.startswith("_"):
continue
attr = getattr(self, attr_name)
# Check if this is a verb-decorated method
if hasattr(attr, "_verb_name"):
self.register_verb(attr._verb_name, attr)
@property @property
def contents(self) -> list[Object]: def contents(self) -> list[Object]:
"""Everything whose location is this object.""" """Everything whose location is this object."""
@ -61,3 +75,15 @@ class Object:
def can_accept(self, obj: Object) -> bool: def can_accept(self, obj: Object) -> bool:
"""Whether this object accepts obj as contents. Default: no.""" """Whether this object accepts obj as contents. Default: no."""
return False return False
def register_verb(self, name: str, handler: Callable[..., Awaitable[None]]) -> None:
"""Register a verb handler on this object."""
self._verbs[name] = handler
def get_verb(self, name: str) -> Callable[..., Awaitable[None]] | None:
"""Get a verb handler by name, or None if not found."""
return self._verbs.get(name)
def has_verb(self, name: str) -> bool:
"""Check if this object has a verb registered."""
return name in self._verbs

View file

@ -15,6 +15,7 @@ import mudlib.combat.commands
import mudlib.commands import mudlib.commands
import mudlib.commands.containers import mudlib.commands.containers
import mudlib.commands.edit import mudlib.commands.edit
import mudlib.commands.examine
import mudlib.commands.fly import mudlib.commands.fly
import mudlib.commands.help import mudlib.commands.help
import mudlib.commands.look import mudlib.commands.look
@ -25,6 +26,7 @@ import mudlib.commands.quit
import mudlib.commands.reload import mudlib.commands.reload
import mudlib.commands.spawn import mudlib.commands.spawn
import mudlib.commands.things import mudlib.commands.things
import mudlib.commands.use
from mudlib.caps import parse_mtts from mudlib.caps import parse_mtts
from mudlib.combat.commands import register_combat_commands from mudlib.combat.commands import register_combat_commands
from mudlib.combat.engine import process_combat from mudlib.combat.engine import process_combat

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

View file

@ -0,0 +1,38 @@
"""Verb handlers for object interactions."""
async def unlock_handler(obj, player, args):
"""
Handle unlocking a container.
Args:
obj: The object being unlocked (should be a Container)
player: The player attempting to unlock
args: Additional arguments (unused)
"""
from mudlib.container import Container
if not isinstance(obj, Container):
await player.send("That can't be unlocked.\r\n")
return
if not obj.locked:
await player.send("That's not locked.\r\n")
return
# Check for key in player inventory
key = None
for item in player.contents:
if item.name.lower() == "key" or "key" in [
a.lower() for a in getattr(item, "aliases", [])
]:
key = item
break
if key is None:
await player.send("You don't have a key.\r\n")
return
obj.locked = False
obj.closed = False
await player.send(f"You unlock the {obj.name} with the {key.name}.\r\n")

62
src/mudlib/verbs.py Normal file
View file

@ -0,0 +1,62 @@
"""Verb system for interactive objects."""
from __future__ import annotations
from collections.abc import Callable
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from mudlib.object import Object
from mudlib.player import Player
def verb(name: str) -> Callable:
"""Decorator to mark a method as a verb handler.
Usage:
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
await player.send("You drink from the fountain.\\r\\n")
"""
def decorator(func: Callable) -> Callable:
func._verb_name = name # type: ignore
return func
return decorator
def find_object(name: str, player: Player) -> Object | None:
"""Find an object by name or alias.
Searches inventory first, then ground at player position.
Works on all Objects that have a name and optional aliases attribute.
"""
name_lower = name.lower()
# Search inventory first
for obj in player.contents:
if obj.name.lower() == name_lower:
return obj
# Check aliases if the object has them
aliases = getattr(obj, "aliases", None)
if aliases and name_lower in (a.lower() for a in aliases):
return obj
# Search ground at player position
if player.location is not None:
from mudlib.zone import Zone
if isinstance(player.location, Zone):
for obj in player.location.contents_at(player.x, player.y):
if obj is player:
continue
if obj.name.lower() == name_lower:
return obj
# Check aliases if the object has them
aliases = getattr(obj, "aliases", None)
if aliases and name_lower in (a.lower() for a in aliases):
return obj
return None

1
tests/__init__.py Normal file
View file

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

206
tests/test_examine.py Normal file
View file

@ -0,0 +1,206 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.examine import cmd_examine
from mudlib.container import Container
from mudlib.entity import Mob
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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,
)
@pytest.mark.asyncio
async def test_examine_no_args(player, mock_writer):
"""examine with no args prompts what to examine"""
await cmd_examine(player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "Examine what?" in output
@pytest.mark.asyncio
async def test_examine_thing_on_ground_by_name(player, test_zone, mock_writer):
"""examine finds Thing on ground by name and shows description"""
_rock = Thing(
name="rock",
x=5,
y=5,
location=test_zone,
description="A smooth gray rock with moss on one side.",
)
await cmd_examine(player, "rock")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A smooth gray rock with moss on one side." in output
@pytest.mark.asyncio
async def test_examine_thing_on_ground_by_alias(player, test_zone, mock_writer):
"""examine finds Thing on ground by alias"""
_rock = Thing(
name="rock",
x=5,
y=5,
location=test_zone,
aliases=["stone"],
description="A smooth gray rock.",
)
await cmd_examine(player, "stone")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A smooth gray rock." in output
@pytest.mark.asyncio
async def test_examine_thing_in_inventory_by_name(player, test_zone, mock_writer):
"""examine finds Thing in inventory by name"""
_key = Thing(
name="key",
x=5,
y=5,
location=player,
description="A rusty iron key.",
)
await cmd_examine(player, "key")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A rusty iron key." in output
@pytest.mark.asyncio
async def test_examine_inventory_before_ground(player, test_zone, mock_writer):
"""examine finds in inventory before ground"""
_ground_rock = Thing(
name="rock",
x=5,
y=5,
location=test_zone,
description="A rock on the ground.",
)
_inventory_rock = Thing(
name="rock",
x=5,
y=5,
location=player,
description="A rock in your pocket.",
)
await cmd_examine(player, "rock")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A rock in your pocket." in output
assert "A rock on the ground." not in output
@pytest.mark.asyncio
async def test_examine_mob_at_position(player, test_zone, mock_writer):
"""examine finds Mob at same position and shows description"""
_goblin = Mob(
name="goblin",
x=5,
y=5,
location=test_zone,
description="A small, greenish creature with beady eyes.",
)
await cmd_examine(player, "goblin")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A small, greenish creature with beady eyes." in output
@pytest.mark.asyncio
async def test_examine_not_found(player, mock_writer):
"""examine nonexistent object shows not found message"""
await cmd_examine(player, "flurb")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "You don't see that here." in output
@pytest.mark.asyncio
async def test_examine_thing_with_empty_description(player, test_zone, mock_writer):
"""examine thing with empty description shows default message"""
_rock = Thing(
name="rock",
x=5,
y=5,
location=test_zone,
description="",
)
await cmd_examine(player, "rock")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "You see nothing special." in output
@pytest.mark.asyncio
async def test_examine_container_shows_description(player, test_zone, mock_writer):
"""examine Container shows description"""
_chest = Container(
name="chest",
x=5,
y=5,
location=test_zone,
description="A sturdy wooden chest with brass hinges.",
closed=False,
)
await cmd_examine(player, "chest")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A sturdy wooden chest with brass hinges." in output
@pytest.mark.asyncio
async def test_examine_alias_ex(player, test_zone, mock_writer):
"""ex alias works"""
_rock = Thing(
name="rock",
x=5,
y=5,
location=test_zone,
description="A smooth rock.",
)
# The command registration will handle the alias, but test function
await cmd_examine(player, "rock")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "A smooth rock." in output

201
tests/test_key_unlock.py Normal file
View file

@ -0,0 +1,201 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.container import Container
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.zone import Zone
@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,
)
@pytest.mark.asyncio
async def test_unlock_handler_on_non_container(player, test_zone, mock_writer):
"""unlock_handler on non-container gives appropriate error"""
from mudlib.verb_handlers import unlock_handler
thing = Thing(
name="rock",
description="a rock",
location=test_zone,
x=5,
y=5,
)
await unlock_handler(thing, player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "can't be unlocked" in output
@pytest.mark.asyncio
async def test_unlock_handler_on_unlocked_container(player, test_zone, mock_writer):
"""unlock_handler on unlocked container says it's not locked"""
from mudlib.verb_handlers import unlock_handler
chest = Container(
name="chest",
description="a wooden chest",
location=test_zone,
x=5,
y=5,
closed=True,
locked=False,
)
await unlock_handler(chest, player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "not locked" in output
@pytest.mark.asyncio
async def test_unlock_handler_without_key(player, test_zone, mock_writer):
"""unlock_handler on locked container without key in inventory"""
from mudlib.verb_handlers import unlock_handler
chest = Container(
name="chest",
description="a wooden chest",
location=test_zone,
x=5,
y=5,
closed=True,
locked=True,
)
await unlock_handler(chest, player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "don't have a key" in output
@pytest.mark.asyncio
async def test_unlock_handler_with_key(player, test_zone, mock_writer):
"""unlock_handler unlocks and opens chest with key in inventory"""
from mudlib.verb_handlers import unlock_handler
chest = Container(
name="chest",
description="a wooden chest",
location=test_zone,
x=5,
y=5,
closed=True,
locked=True,
)
key = Thing(
name="key",
description="a brass key",
location=player,
)
assert key in player.contents
await unlock_handler(chest, player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "You unlock the chest with the key" in output
assert not chest.locked
assert not chest.closed
@pytest.mark.asyncio
async def test_unlock_handler_key_found_by_alias(player, test_zone, mock_writer):
"""unlock_handler finds key even if it has a different name"""
from mudlib.verb_handlers import unlock_handler
chest = Container(
name="chest",
description="a wooden chest",
location=test_zone,
x=5,
y=5,
closed=True,
locked=True,
)
key = Thing(
name="brass key",
description="a brass key",
location=player,
)
key.aliases = ["key"]
await unlock_handler(chest, player, "")
output = "".join(call[0][0] for call in mock_writer.write.call_args_list)
assert "You unlock the chest with the brass key" in output
assert not chest.locked
assert not chest.closed
@pytest.mark.asyncio
async def test_unlock_handler_preserves_chest_state(player, test_zone, mock_writer):
"""unlock_handler changes locked and closed but preserves other state"""
from mudlib.verb_handlers import unlock_handler
chest = Container(
name="chest",
description="a wooden chest",
location=test_zone,
x=5,
y=5,
closed=True,
locked=True,
)
inner_thing = Thing(
name="coin",
description="a gold coin",
location=chest,
)
key = Thing(
name="key",
description="a brass key",
location=player,
)
assert key in player.contents
await unlock_handler(chest, player, "")
assert not chest.locked
assert not chest.closed
assert inner_thing in chest.contents
assert chest.location == test_zone

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

180
tests/test_use.py Normal file
View file

@ -0,0 +1,180 @@
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands.use import cmd_use
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.verbs import verb
from mudlib.zone import Zone
@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 get_output(mock_writer):
"""Extract all text written to the mock writer."""
return "".join(call[0][0] for call in mock_writer.write.call_args_list)
@pytest.mark.asyncio
async def test_use_no_args(player, mock_writer):
"""use with no args should prompt for what to use"""
await cmd_use(player, "")
output = get_output(mock_writer)
assert "Use what?" in output
@pytest.mark.asyncio
async def test_use_object_with_verb(player, mock_writer, test_zone):
"""use rock should find rock with use verb and call it"""
class Rock(Thing):
@verb("use")
async def use_verb(self, player, args):
await player.send("You use the rock.\r\n")
_rock = Rock(
name="rock", description="a smooth rock", portable=True, location=test_zone
)
_rock.x = 5
_rock.y = 5
await cmd_use(player, "rock")
output = get_output(mock_writer)
assert "You use the rock." in output
@pytest.mark.asyncio
async def test_use_object_without_verb(player, mock_writer, test_zone):
"""use rock when rock has no use verb should give error"""
_rock = Thing(
name="rock", description="a smooth rock", portable=True, location=test_zone
)
_rock.x = 5
_rock.y = 5
await cmd_use(player, "rock")
output = get_output(mock_writer)
assert "You can't use that." in output
@pytest.mark.asyncio
async def test_use_object_not_found(player, mock_writer):
"""use flurb when nothing matches should give error"""
await cmd_use(player, "flurb")
output = get_output(mock_writer)
assert "You don't see that here." in output
@pytest.mark.asyncio
async def test_use_object_on_target(player, mock_writer, test_zone):
"""use key on chest should pass chest as args to verb"""
class Key(Thing):
@verb("use")
async def use_verb(self, player, args):
await player.send(f"You use the key on {args}.\r\n")
_key = Key(name="key", description="a brass key", portable=True, location=test_zone)
_key.x = 5
_key.y = 5
await cmd_use(player, "key on chest")
output = get_output(mock_writer)
assert "You use the key on chest." in output
@pytest.mark.asyncio
async def test_use_object_on_nonexistent_target(player, mock_writer, test_zone):
"""use key on flurb should still work, passing flurb to verb handler"""
class Key(Thing):
@verb("use")
async def use_verb(self, player, args):
await player.send(f"You use the key on {args}.\r\n")
_key = Key(name="key", description="a brass key", portable=True, location=test_zone)
_key.x = 5
_key.y = 5
await cmd_use(player, "key on flurb")
output = get_output(mock_writer)
assert "You use the key on flurb." in output
@pytest.mark.asyncio
async def test_use_object_in_inventory(player, mock_writer):
"""use should find objects in inventory"""
class Potion(Thing):
@verb("use")
async def use_verb(self, player, args):
await player.send("You drink the potion.\r\n")
_potion = Potion(
name="potion", description="a healing potion", portable=True, location=player
)
await cmd_use(player, "potion")
output = get_output(mock_writer)
assert "You drink the potion." in output
@pytest.mark.asyncio
async def test_use_passes_correct_args(player, mock_writer, test_zone):
"""verify verb handler receives correct args string"""
received_args = None
class Tool(Thing):
@verb("use")
async def use_verb(self, player, args):
nonlocal received_args
received_args = args
await player.send(f"Used with args: '{args}'\r\n")
_tool = Tool(
name="tool", description="a multi-tool", portable=True, location=test_zone
)
_tool.x = 5
_tool.y = 5
# Test with no target
await cmd_use(player, "tool")
assert received_args == ""
# Reset writer
mock_writer.write.reset_mock()
received_args = None
# Test with target
await cmd_use(player, "tool on something")
assert received_args == "something"

195
tests/test_verb_dispatch.py Normal file
View file

@ -0,0 +1,195 @@
"""Tests for verb dispatch fallback in command system."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import dispatch
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.verbs import verb
from mudlib.zone import Zone
@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,
)
class Fountain(Thing):
"""Test object with drink verb."""
@verb("drink")
async def drink(self, player, args):
await player.send("You drink from the fountain.\r\n")
@verb("splash")
async def splash(self, player, args):
await player.send(f"You splash the fountain{' ' + args if args else ''}.\r\n")
@pytest.mark.asyncio
async def test_verb_dispatch_simple(player, test_zone):
"""Test 'drink fountain' dispatches to fountain's drink verb."""
_fountain = Fountain(
name="fountain",
description="a stone fountain",
portable=False,
location=test_zone,
x=5,
y=5,
)
await dispatch(player, "drink fountain")
player.writer.write.assert_called_once_with("You drink from the fountain.\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_with_extra_args(player, test_zone):
"""Test 'splash fountain hard' passes 'hard' as extra args."""
_fountain = Fountain(
name="fountain",
description="a stone fountain",
portable=False,
location=test_zone,
x=5,
y=5,
)
await dispatch(player, "splash fountain hard")
player.writer.write.assert_called_once_with("You splash the fountain hard.\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_object_lacks_verb(player, test_zone):
"""Test 'drink rock' fails if rock doesn't have drink verb."""
_rock = Thing(
name="rock",
description="a plain rock",
portable=True,
location=test_zone,
x=5,
y=5,
)
await dispatch(player, "drink rock")
player.writer.write.assert_called_once_with("Unknown command: drink\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_object_not_found(player, test_zone):
"""Test 'drink flurb' fails if flurb doesn't exist."""
await dispatch(player, "drink flurb")
player.writer.write.assert_called_once_with("Unknown command: drink\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_no_args(player, test_zone):
"""Test 'drink' with no args doesn't try verb dispatch."""
await dispatch(player, "drink")
player.writer.write.assert_called_once_with("Unknown command: drink\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_doesnt_intercept_real_commands(player, test_zone):
"""Test that verb dispatch doesn't interfere with registered commands."""
from mudlib.commands import CommandDefinition, register, unregister
called = False
async def test_command(player, args):
nonlocal called
called = True
await player.send("Real command executed.\r\n")
register(CommandDefinition("test", test_command))
try:
await dispatch(player, "test something")
assert called
player.writer.write.assert_called_once_with("Real command executed.\r\n")
finally:
unregister("test")
@pytest.mark.asyncio
async def test_verb_dispatch_finds_inventory_object(player, test_zone):
"""Test verb dispatch finds object in inventory."""
class Potion(Thing):
@verb("drink")
async def drink(self, player, args):
await player.send("You drink the potion.\r\n")
_potion = Potion(
name="potion",
description="a health potion",
portable=True,
location=player,
)
await dispatch(player, "drink potion")
player.writer.write.assert_called_once_with("You drink the potion.\r\n")
player.writer.drain.assert_awaited_once()
@pytest.mark.asyncio
async def test_verb_dispatch_doesnt_fire_on_ambiguous_command(player, test_zone):
"""Test verb dispatch doesn't fire when command is ambiguous."""
from mudlib.commands import CommandDefinition, register, unregister
async def test_one(player, args):
await player.send("test one\r\n")
async def test_two(player, args):
await player.send("test two\r\n")
register(CommandDefinition("testone", test_one))
register(CommandDefinition("testtwo", test_two))
try:
# "test" matches both testone and testtwo
await dispatch(player, "test something")
# Should get ambiguous command message, not verb dispatch
written_text = player.writer.write.call_args[0][0].lower()
# The ambiguous message is "testone or testtwo?" not "ambiguous"
assert "or" in written_text and "?" in written_text
finally:
unregister("testone")
unregister("testtwo")

243
tests/test_verbs.py Normal file
View file

@ -0,0 +1,243 @@
"""Tests for the verb system on Object."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.object import Object
from mudlib.player import Player
from mudlib.thing import Thing
from mudlib.verbs import find_object, verb
from mudlib.zone import Zone
@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_object_starts_with_empty_verbs():
"""Object starts with empty _verbs dict."""
obj = Object(name="rock")
assert obj._verbs == {}
def test_register_verb():
"""register_verb adds a verb to the _verbs dict."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert "drink" in obj._verbs
assert obj._verbs["drink"] is test_handler
def test_get_verb_returns_handler():
"""get_verb returns the registered handler."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert obj.get_verb("drink") is test_handler
def test_get_verb_returns_none_for_unknown():
"""get_verb returns None for unknown verb."""
obj = Object(name="rock")
assert obj.get_verb("nonexistent") is None
def test_has_verb_returns_true_when_present():
"""has_verb returns True when verb is registered."""
async def test_handler(obj, player, args):
pass
obj = Object(name="fountain")
obj.register_verb("drink", test_handler)
assert obj.has_verb("drink") is True
def test_has_verb_returns_false_when_absent():
"""has_verb returns False when verb is not registered."""
obj = Object(name="rock")
assert obj.has_verb("drink") is False
def test_verb_decorator_marks_method():
"""@verb decorator marks method with _verb_name attribute."""
class TestObject(Object):
@verb("test")
async def test_verb(self, player, args):
pass
assert hasattr(TestObject.test_verb, "_verb_name")
assert TestObject.test_verb._verb_name == "test"
def test_verb_decorator_auto_registers_on_instantiation():
"""Class with @verb methods auto-registers verbs on instantiation."""
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
fountain = Fountain(name="fountain")
assert fountain.has_verb("drink")
assert fountain.get_verb("drink") is not None
def test_multiple_verbs_on_same_class():
"""Multiple verbs on same class all register."""
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
@verb("splash")
async def splash(self, player, args):
pass
fountain = Fountain(name="fountain")
assert fountain.has_verb("drink")
assert fountain.has_verb("splash")
@pytest.mark.asyncio
async def test_verb_handler_can_be_called(player):
"""Verb handler can be called as an async function."""
called = False
received_args = None
class Fountain(Thing):
@verb("drink")
async def drink(self, player, args):
nonlocal called, received_args
called = True
received_args = args
fountain = Fountain(name="fountain")
handler = fountain.get_verb("drink")
assert handler is not None
# handler is a bound method, so we don't pass self/fountain again
await handler(player, "from the left side")
assert called
assert received_args == "from the left side"
def test_verb_registered_on_parent_class_propagates_to_subclass():
"""Verb registered on parent class propagates to subclass instances."""
class BaseFountain(Thing):
@verb("drink")
async def drink(self, player, args):
pass
class FancyFountain(BaseFountain):
@verb("splash")
async def splash(self, player, args):
pass
fancy = FancyFountain(name="fancy fountain")
assert fancy.has_verb("drink")
assert fancy.has_verb("splash")
def test_find_object_by_name_in_inventory(player):
"""find_object finds object by name in inventory."""
sword = Thing(name="sword", location=player)
found = find_object("sword", player)
assert found is sword
def test_find_object_by_alias_in_inventory(player):
"""find_object finds object by alias in inventory."""
can = Thing(name="pepsi can", aliases=["can", "pepsi"], location=player)
found = find_object("can", player)
assert found is can
def test_find_object_by_name_on_ground(player, test_zone):
"""find_object finds object by name on ground."""
rock = Thing(name="rock", location=test_zone, x=5, y=5)
found = find_object("rock", player)
assert found is rock
def test_find_object_by_alias_on_ground(player, test_zone):
"""find_object finds object by alias on ground."""
can = Thing(
name="pepsi can", aliases=["can", "pepsi"], location=test_zone, x=5, y=5
)
found = find_object("pepsi", player)
assert found is can
def test_find_object_prefers_inventory_over_ground(player, test_zone):
"""find_object prefers inventory over ground when matching object in both."""
Thing(name="sword", location=test_zone, x=5, y=5)
inv_sword = Thing(name="sword", location=player)
found = find_object("sword", player)
assert found is inv_sword
def test_find_object_returns_none_when_nothing_matches(player):
"""find_object returns None when nothing matches."""
found = find_object("nonexistent", player)
assert found is None
def test_find_object_searches_all_objects_not_just_things(player, test_zone):
"""find_object searches all Objects, not just Things."""
class CustomObject(Object):
def __init__(self, name, aliases=None, **kwargs):
super().__init__(name=name, **kwargs)
self.aliases = aliases or []
custom = CustomObject(name="widget", aliases=["gadget"], location=player)
found = find_object("widget", player)
assert found is custom
found_by_alias = find_object("gadget", player)
assert found_by_alias is custom
def test_find_object_skips_self(player, test_zone):
"""find_object should not return the querying player."""
player.move_to(test_zone, x=0, y=0)
result = find_object(player.name, player)
assert result is None