Add readable objects with read command

Implements TDD feature for readable text on Things:
- Added readable_text field to Thing dataclass
- Extended ThingTemplate to parse readable_text from TOML
- Created read command that finds objects by name/alias in inventory or on ground
- Handles edge cases: no target, not found, not readable
This commit is contained in:
Jared Miller 2026-02-14 11:51:52 -05:00
parent 5e0ec120c6
commit 68c18572d6
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
4 changed files with 347 additions and 0 deletions

View file

@ -0,0 +1,56 @@
"""Read command for examining readable objects."""
from mudlib.commands import CommandDefinition, register
from mudlib.player import Player
from mudlib.thing import Thing
def _find_readable(player: Player, name: str) -> Thing | None:
"""Find a readable thing by name or alias in inventory or on ground."""
name_lower = name.lower()
# Check inventory first
for obj in player._contents:
if not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if any(a.lower() == name_lower for a in obj.aliases):
return obj
# Check ground at player's 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 not isinstance(obj, Thing):
continue
if obj.name.lower() == name_lower:
return obj
if any(a.lower() == name_lower for a in obj.aliases):
return obj
return None
async def cmd_read(player: Player, args: str) -> None:
"""Read a readable object."""
target_name = args.strip()
if not target_name:
await player.send("Read what?\r\n")
return
thing = _find_readable(player, target_name)
if thing is None:
await player.send("You don't see that here.\r\n")
return
if not thing.readable_text:
await player.send("There's nothing to read on that.\r\n")
return
await player.send(f"You read the {thing.name}:\r\n{thing.readable_text}\r\n")
register(CommandDefinition("read", cmd_read, help="Read a readable object"))

View file

@ -19,3 +19,4 @@ class Thing(Object):
description: str = "" description: str = ""
portable: bool = True portable: bool = True
aliases: list[str] = field(default_factory=list) aliases: list[str] = field(default_factory=list)
readable_text: str = ""

View file

@ -29,6 +29,7 @@ class ThingTemplate:
locked: bool = False locked: bool = False
# Verb handlers (verb_name -> module:function reference) # Verb handlers (verb_name -> module:function reference)
verbs: dict[str, str] = field(default_factory=dict) verbs: dict[str, str] = field(default_factory=dict)
readable_text: str = ""
# Module-level registry # Module-level registry
@ -59,6 +60,7 @@ def load_thing_template(path: Path) -> ThingTemplate:
closed=data.get("closed", False), closed=data.get("closed", False),
locked=data.get("locked", False), locked=data.get("locked", False),
verbs=data.get("verbs", {}), verbs=data.get("verbs", {}),
readable_text=data.get("readable_text", ""),
) )
@ -86,6 +88,7 @@ def spawn_thing(
description=template.description, description=template.description,
portable=template.portable, portable=template.portable,
aliases=list(template.aliases), aliases=list(template.aliases),
readable_text=template.readable_text,
capacity=template.capacity, capacity=template.capacity,
closed=template.closed, closed=template.closed,
locked=template.locked, locked=template.locked,
@ -99,6 +102,7 @@ def spawn_thing(
description=template.description, description=template.description,
portable=template.portable, portable=template.portable,
aliases=list(template.aliases), aliases=list(template.aliases),
readable_text=template.readable_text,
location=location, location=location,
x=x, x=x,
y=y, y=y,

286
tests/test_readable.py Normal file
View file

@ -0,0 +1,286 @@
"""Tests for readable objects."""
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.player import Player, players
from mudlib.thing import Thing
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def clear_state():
players.clear()
yield
players.clear()
@pytest.fixture
def zone():
terrain = [["." for _ in range(10)] for _ in range(10)]
return Zone(
name="library",
width=10,
height=10,
terrain=terrain,
)
@pytest.fixture
def mock_writer():
writer = MagicMock()
writer.write = MagicMock()
writer.drain = AsyncMock()
return writer
@pytest.fixture
def mock_reader():
return MagicMock()
# --- Thing model ---
def test_thing_readable_text_default():
"""Thing has empty readable_text by default."""
t = Thing(name="rock")
assert t.readable_text == ""
def test_thing_readable_text_set():
"""Thing can have readable_text."""
t = Thing(name="sign", readable_text="welcome to town")
assert t.readable_text == "welcome to town"
# --- Template loading ---
def test_thing_template_readable_text():
"""ThingTemplate parses readable_text from TOML data."""
import pathlib
import tempfile
from mudlib.things import load_thing_template
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "old sign"
description = "a weathered wooden sign"
portable = false
readable_text = "beware of the goblin king"
""")
temp_path = pathlib.Path(f.name)
try:
template = load_thing_template(temp_path)
assert template.readable_text == "beware of the goblin king"
finally:
temp_path.unlink()
def test_thing_template_readable_text_default():
"""ThingTemplate defaults readable_text to empty string."""
import pathlib
import tempfile
from mudlib.things import load_thing_template
with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
f.write("""
name = "rock"
description = "a plain rock"
""")
temp_path = pathlib.Path(f.name)
try:
template = load_thing_template(temp_path)
assert template.readable_text == ""
finally:
temp_path.unlink()
def test_spawn_thing_readable_text():
"""spawn_thing passes readable_text to Thing instance."""
from mudlib.things import ThingTemplate, spawn_thing
template = ThingTemplate(
name="notice",
description="a posted notice",
readable_text="town meeting at noon",
)
thing = spawn_thing(template, location=None)
assert thing.readable_text == "town meeting at noon"
# --- Read command ---
@pytest.mark.asyncio
async def test_read_thing_on_ground(zone, mock_writer, mock_reader):
"""Reading a thing on the ground shows its text."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
players["reader"] = player
Thing(
name="sign",
description="a wooden sign",
readable_text="welcome to the forest",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "sign")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "welcome to the forest" in written
@pytest.mark.asyncio
async def test_read_thing_in_inventory(zone, mock_writer, mock_reader):
"""Reading a thing in inventory shows its text."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
players["reader"] = player
Thing(
name="scroll",
description="an ancient scroll",
readable_text="the ancient prophecy speaks of...",
location=player,
)
await cmd_read(player, "scroll")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "the ancient prophecy speaks of" in written
@pytest.mark.asyncio
async def test_read_no_target(zone, mock_writer, mock_reader):
"""Reading without a target shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
await cmd_read(player, "")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "read what" in written.lower()
@pytest.mark.asyncio
async def test_read_not_found(zone, mock_writer, mock_reader):
"""Reading something not present shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
await cmd_read(player, "scroll")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "don't see" in written.lower() or "nothing" in written.lower()
@pytest.mark.asyncio
async def test_read_not_readable(zone, mock_writer, mock_reader):
"""Reading a thing with no readable_text shows error."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
Thing(
name="rock",
description="a plain rock",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "rock")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[0][0][0]
assert "nothing to read" in written.lower()
@pytest.mark.asyncio
async def test_read_by_alias(zone, mock_writer, mock_reader):
"""Can read a thing by its alias."""
from mudlib.commands.read import cmd_read
player = Player(
name="reader",
x=5,
y=5,
writer=mock_writer,
reader=mock_reader,
location=zone,
)
zone._contents.append(player)
Thing(
name="leather-bound book",
aliases=["book", "tome"],
description="a leather-bound book",
readable_text="once upon a time...",
location=zone,
x=5,
y=5,
)
await cmd_read(player, "tome")
mock_writer.write.assert_called()
written = mock_writer.write.call_args_list[-1][0][0]
assert "once upon a time" in written