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.
This commit is contained in:
Jared Miller 2026-02-11 21:23:35 -05:00
parent fcfa13c785
commit 9534df8f9c
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 288 additions and 3 deletions

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

@ -76,9 +76,7 @@ class Object:
"""Whether this object accepts obj as contents. Default: no."""
return False
def register_verb(
self, name: str, handler: Callable[..., Awaitable[None]]
) -> None:
def register_verb(self, name: str, handler: Callable[..., Awaitable[None]]) -> None:
"""Register a verb handler on this object."""
self._verbs[name] = handler

View file

@ -15,6 +15,7 @@ import mudlib.combat.commands
import mudlib.commands
import mudlib.commands.containers
import mudlib.commands.edit
import mudlib.commands.examine
import mudlib.commands.fly
import mudlib.commands.help
import mudlib.commands.look

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