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:
parent
5e0ec120c6
commit
68c18572d6
4 changed files with 347 additions and 0 deletions
56
src/mudlib/commands/read.py
Normal file
56
src/mudlib/commands/read.py
Normal 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"))
|
||||
|
|
@ -19,3 +19,4 @@ class Thing(Object):
|
|||
description: str = ""
|
||||
portable: bool = True
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
readable_text: str = ""
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ class ThingTemplate:
|
|||
locked: bool = False
|
||||
# Verb handlers (verb_name -> module:function reference)
|
||||
verbs: dict[str, str] = field(default_factory=dict)
|
||||
readable_text: str = ""
|
||||
|
||||
|
||||
# Module-level registry
|
||||
|
|
@ -59,6 +60,7 @@ def load_thing_template(path: Path) -> ThingTemplate:
|
|||
closed=data.get("closed", False),
|
||||
locked=data.get("locked", False),
|
||||
verbs=data.get("verbs", {}),
|
||||
readable_text=data.get("readable_text", ""),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -86,6 +88,7 @@ def spawn_thing(
|
|||
description=template.description,
|
||||
portable=template.portable,
|
||||
aliases=list(template.aliases),
|
||||
readable_text=template.readable_text,
|
||||
capacity=template.capacity,
|
||||
closed=template.closed,
|
||||
locked=template.locked,
|
||||
|
|
@ -99,6 +102,7 @@ def spawn_thing(
|
|||
description=template.description,
|
||||
portable=template.portable,
|
||||
aliases=list(template.aliases),
|
||||
readable_text=template.readable_text,
|
||||
location=location,
|
||||
x=x,
|
||||
y=y,
|
||||
|
|
|
|||
286
tests/test_readable.py
Normal file
286
tests/test_readable.py
Normal 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
|
||||
Loading…
Reference in a new issue