From 9534df8f9c59cfa8286a262a80a90e80bd40e472 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 21:23:35 -0500 Subject: [PATCH] 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. --- src/mudlib/commands/examine.py | 80 +++++++++++++ src/mudlib/object.py | 4 +- src/mudlib/server.py | 1 + tests/test_examine.py | 206 +++++++++++++++++++++++++++++++++ 4 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 src/mudlib/commands/examine.py create mode 100644 tests/test_examine.py diff --git a/src/mudlib/commands/examine.py b/src/mudlib/commands/examine.py new file mode 100644 index 0000000..6fc5f2e --- /dev/null +++ b/src/mudlib/commands/examine.py @@ -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="*")) diff --git a/src/mudlib/object.py b/src/mudlib/object.py index aff45c5..25b60fa 100644 --- a/src/mudlib/object.py +++ b/src/mudlib/object.py @@ -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 diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 4efa1b2..e1bf713 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -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 diff --git a/tests/test_examine.py b/tests/test_examine.py new file mode 100644 index 0000000..4378dc6 --- /dev/null +++ b/tests/test_examine.py @@ -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