diff --git a/src/mudlib/commands/read.py b/src/mudlib/commands/read.py new file mode 100644 index 0000000..a345162 --- /dev/null +++ b/src/mudlib/commands/read.py @@ -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")) diff --git a/src/mudlib/thing.py b/src/mudlib/thing.py index d6c06eb..54cca75 100644 --- a/src/mudlib/thing.py +++ b/src/mudlib/thing.py @@ -19,3 +19,4 @@ class Thing(Object): description: str = "" portable: bool = True aliases: list[str] = field(default_factory=list) + readable_text: str = "" diff --git a/src/mudlib/things.py b/src/mudlib/things.py index 2d7f9ec..6c81771 100644 --- a/src/mudlib/things.py +++ b/src/mudlib/things.py @@ -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, diff --git a/tests/test_readable.py b/tests/test_readable.py new file mode 100644 index 0000000..949ddab --- /dev/null +++ b/tests/test_readable.py @@ -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