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:
parent
fcfa13c785
commit
9534df8f9c
4 changed files with 288 additions and 3 deletions
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="*"))
|
||||||
|
|
@ -76,9 +76,7 @@ class Object:
|
||||||
"""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(
|
def register_verb(self, name: str, handler: Callable[..., Awaitable[None]]) -> None:
|
||||||
self, name: str, handler: Callable[..., Awaitable[None]]
|
|
||||||
) -> None:
|
|
||||||
"""Register a verb handler on this object."""
|
"""Register a verb handler on this object."""
|
||||||
self._verbs[name] = handler
|
self._verbs[name] = handler
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
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
|
||||||
Loading…
Reference in a new issue