diff --git a/src/mudlib/commands/things.py b/src/mudlib/commands/things.py new file mode 100644 index 0000000..6b3dbe8 --- /dev/null +++ b/src/mudlib/commands/things.py @@ -0,0 +1,80 @@ +"""Get, drop, and inventory commands for items.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.zone import Zone + + +def _find_thing_at(name: str, zone: Zone, x: int, y: int) -> Thing | None: + """Find a thing on the ground matching name or alias.""" + name_lower = name.lower() + for obj in zone.contents_at(x, y): + if not isinstance(obj, Thing): + continue + if obj.name.lower() == name_lower: + return obj + if name_lower in (a.lower() for a in obj.aliases): + return obj + return None + + +def _find_thing_in_inventory(name: str, player: Player) -> Thing | None: + """Find a thing in the player's inventory matching name or alias.""" + name_lower = name.lower() + for obj in player.contents: + if not isinstance(obj, Thing): + continue + if obj.name.lower() == name_lower: + return obj + if name_lower in (a.lower() for a in obj.aliases): + return obj + return None + + +async def cmd_get(player: Player, args: str) -> None: + """Pick up an item from the ground.""" + if not args.strip(): + await player.send("Get what?\r\n") + return + + zone = player.location + if zone is None or not isinstance(zone, Zone): + await player.send("You are nowhere.\r\n") + return + + thing = _find_thing_at(args.strip(), zone, player.x, player.y) + if thing is None: + await player.send(f"You don't see '{args.strip()}' here.\r\n") + return + + if not player.can_accept(thing): + await player.send(f"You can't pick that up.\r\n") + return + + thing.move_to(player) + await player.send(f"You pick up {thing.name}.\r\n") + + +async def cmd_drop(player: Player, args: str) -> None: + """Drop an item from inventory onto the ground.""" + if not args.strip(): + await player.send("Drop what?\r\n") + return + + zone = player.location + if zone is None or not isinstance(zone, Zone): + await player.send("You can't drop things here.\r\n") + return + + thing = _find_thing_in_inventory(args.strip(), player) + if thing is None: + await player.send(f"You're not carrying '{args.strip()}'.\r\n") + return + + thing.move_to(zone, x=player.x, y=player.y) + await player.send(f"You drop {thing.name}.\r\n") + + +register(CommandDefinition("get", cmd_get, aliases=["take", "pick"])) +register(CommandDefinition("drop", cmd_drop)) diff --git a/src/mudlib/object.py b/src/mudlib/object.py index ed000c2..79f54f1 100644 --- a/src/mudlib/object.py +++ b/src/mudlib/object.py @@ -32,6 +32,32 @@ class Object: """Everything whose location is this object.""" return list(self._contents) + def move_to( + self, + destination: Object | None, + *, + x: int | None = None, + y: int | None = None, + ) -> None: + """Move this object to a new location. + + Removes from old location's contents, updates the location pointer, + and adds to new location's contents. Coordinates are set from the + keyword arguments (cleared to None if not provided). + """ + # Remove from old location + if self.location is not None and self in self.location._contents: + self.location._contents.remove(self) + + # Update location and coordinates + self.location = destination + self.x = x + self.y = y + + # Add to new location + if destination is not None: + destination._contents.append(self) + def can_accept(self, obj: Object) -> bool: """Whether this object accepts obj as contents. Default: no.""" return False diff --git a/tests/test_get_drop.py b/tests/test_get_drop.py new file mode 100644 index 0000000..bef3c00 --- /dev/null +++ b/tests/test_get_drop.py @@ -0,0 +1,290 @@ +"""Tests for get and drop commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands import CommandDefinition, _registry +from mudlib.entity import Entity +from mudlib.object import Object +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, + toroidal=True, + terrain=terrain, + ) + + +@pytest.fixture +def player(mock_reader, mock_writer, test_zone): + p = Player( + name="TestPlayer", + x=5, y=5, + reader=mock_reader, + writer=mock_writer, + location=test_zone, + ) + return p + + +# --- Object.move_to --- + + +def test_move_to_updates_location(): + """move_to changes the object's location pointer.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + entity = Entity(name="player", location=zone, x=5, y=5) + rock = Thing(name="rock", location=zone, x=5, y=5) + + rock.move_to(entity) + assert rock.location is entity + + +def test_move_to_removes_from_old_contents(): + """move_to removes object from old location's contents.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + entity = Entity(name="player", location=zone, x=5, y=5) + rock = Thing(name="rock", location=zone, x=5, y=5) + + rock.move_to(entity) + assert rock not in zone.contents + + +def test_move_to_adds_to_new_contents(): + """move_to adds object to new location's contents.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + entity = Entity(name="player", location=zone, x=5, y=5) + rock = Thing(name="rock", location=zone, x=5, y=5) + + rock.move_to(entity) + assert rock in entity.contents + + +def test_move_to_from_none(): + """move_to works from location=None (template to world).""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + rock = Thing(name="rock") + + rock.move_to(zone, x=3, y=7) + assert rock.location is zone + assert rock.x == 3 + assert rock.y == 7 + assert rock in zone.contents + + +def test_move_to_sets_coordinates(): + """move_to can set x/y coordinates.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + rock = Thing(name="rock", location=zone, x=1, y=1) + + entity = Entity(name="player", location=zone, x=5, y=5) + rock.move_to(entity) + # When moving to inventory, coordinates should be cleared + assert rock.x is None + assert rock.y is None + + +def test_move_to_zone_sets_coordinates(): + """move_to a zone sets x/y from args.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + entity = Entity(name="player", location=zone, x=5, y=5) + rock = Thing(name="rock", location=entity) + + rock.move_to(zone, x=3, y=7) + assert rock.x == 3 + assert rock.y == 7 + + +# --- cmd_get --- + + +@pytest.mark.asyncio +async def test_get_picks_up_thing(player, test_zone): + """get moves a thing from zone to player inventory.""" + from mudlib.commands.things import cmd_get + + rock = Thing(name="rock", location=test_zone, x=5, y=5) + await cmd_get(player, "rock") + assert rock.location is player + assert rock in player.contents + assert rock not in test_zone.contents + + +@pytest.mark.asyncio +async def test_get_sends_confirmation(player, test_zone, mock_writer): + """get sends feedback to the player.""" + from mudlib.commands.things import cmd_get + + Thing(name="rock", location=test_zone, x=5, y=5) + await cmd_get(player, "rock") + output = mock_writer.write.call_args_list[-1][0][0] + assert "rock" in output.lower() + + +@pytest.mark.asyncio +async def test_get_nothing_there(player, test_zone, mock_writer): + """get with no matching item gives feedback.""" + from mudlib.commands.things import cmd_get + + await cmd_get(player, "sword") + output = mock_writer.write.call_args_list[-1][0][0] + assert "don't see" in output.lower() or "nothing" in output.lower() + + +@pytest.mark.asyncio +async def test_get_non_portable(player, test_zone, mock_writer): + """get rejects non-portable things.""" + from mudlib.commands.things import cmd_get + + fountain = Thing( + name="fountain", location=test_zone, x=5, y=5, portable=False, + ) + await cmd_get(player, "fountain") + # Fountain should still be in zone, not in player + assert fountain.location is test_zone + assert fountain not in player.contents + output = mock_writer.write.call_args_list[-1][0][0] + assert "can't" in output.lower() + + +@pytest.mark.asyncio +async def test_get_no_args(player, mock_writer): + """get with no arguments gives usage hint.""" + from mudlib.commands.things import cmd_get + + await cmd_get(player, "") + output = mock_writer.write.call_args_list[-1][0][0] + assert "get what" in output.lower() or "what" in output.lower() + + +@pytest.mark.asyncio +async def test_get_matches_aliases(player, test_zone): + """get matches thing aliases.""" + from mudlib.commands.things import cmd_get + + can = Thing( + name="pepsi can", + aliases=["can", "pepsi"], + location=test_zone, x=5, y=5, + ) + await cmd_get(player, "pepsi") + assert can.location is player + + +# --- cmd_drop --- + + +@pytest.mark.asyncio +async def test_drop_puts_thing_on_ground(player, test_zone): + """drop moves a thing from inventory to zone at player's position.""" + from mudlib.commands.things import cmd_drop + + rock = Thing(name="rock", location=player) + await cmd_drop(player, "rock") + assert rock.location is test_zone + assert rock.x == player.x + assert rock.y == player.y + assert rock in test_zone.contents + assert rock not in player.contents + + +@pytest.mark.asyncio +async def test_drop_sends_confirmation(player, test_zone, mock_writer): + """drop sends feedback to the player.""" + from mudlib.commands.things import cmd_drop + + Thing(name="rock", location=player) + await cmd_drop(player, "rock") + output = mock_writer.write.call_args_list[-1][0][0] + assert "rock" in output.lower() + + +@pytest.mark.asyncio +async def test_drop_not_carrying(player, mock_writer): + """drop with item not in inventory gives feedback.""" + from mudlib.commands.things import cmd_drop + + await cmd_drop(player, "sword") + output = mock_writer.write.call_args_list[-1][0][0] + assert "not carrying" in output.lower() or "don't have" in output.lower() + + +@pytest.mark.asyncio +async def test_drop_no_args(player, mock_writer): + """drop with no arguments gives usage hint.""" + from mudlib.commands.things import cmd_drop + + await cmd_drop(player, "") + output = mock_writer.write.call_args_list[-1][0][0] + assert "drop what" in output.lower() or "what" in output.lower() + + +@pytest.mark.asyncio +async def test_drop_requires_zone(player, mock_writer): + """drop without a zone location gives error.""" + from mudlib.commands.things import cmd_drop + + player.location = None + Thing(name="rock", location=player) + await cmd_drop(player, "rock") + output = mock_writer.write.call_args_list[-1][0][0] + assert "nowhere" in output.lower() or "can't" in output.lower() + + +@pytest.mark.asyncio +async def test_drop_matches_aliases(player, test_zone): + """drop matches thing aliases.""" + from mudlib.commands.things import cmd_drop + + can = Thing( + name="pepsi can", + aliases=["can", "pepsi"], + location=player, + ) + await cmd_drop(player, "can") + assert can.location is test_zone + + +# --- command registration --- + + +def test_get_command_registered(): + """get command is registered.""" + import mudlib.commands.things # noqa: F401 + + assert "get" in _registry + + +def test_drop_command_registered(): + """drop command is registered.""" + import mudlib.commands.things # noqa: F401 + + assert "drop" in _registry