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 = ""
|
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 = ""
|
||||||
|
|
|
||||||
|
|
@ -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
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