Compare commits
6 commits
7d4a75f973
...
e724abb926
| Author | SHA1 | Date | |
|---|---|---|---|
| e724abb926 | |||
| 6ce57ad970 | |||
| d2de6bdc16 | |||
| 9534df8f9c | |||
| fcfa13c785 | |||
| 4ec09cffda |
17 changed files with 1582 additions and 12 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
80
src/mudlib/commands/examine.py
Normal file
80
src/mudlib/commands/examine.py
Normal 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="*"))
|
||||||
40
src/mudlib/commands/use.py
Normal file
40
src/mudlib/commands/use.py
Normal 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="*"))
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
38
src/mudlib/verb_handlers.py
Normal file
38
src/mudlib/verb_handlers.py
Normal 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
62
src/mudlib/verbs.py
Normal 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
1
tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Tests for mudlib."""
|
||||||
206
tests/test_examine.py
Normal file
206
tests/test_examine.py
Normal 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
201
tests/test_key_unlock.py
Normal 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
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")
|
||||||
180
tests/test_use.py
Normal file
180
tests/test_use.py
Normal 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
195
tests/test_verb_dispatch.py
Normal 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
243
tests/test_verbs.py
Normal 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
|
||||||
Loading…
Reference in a new issue