diff --git a/src/mudlib/commands/containers.py b/src/mudlib/commands/containers.py index fa31459..951ab2d 100644 --- a/src/mudlib/commands/containers.py +++ b/src/mudlib/commands/containers.py @@ -90,5 +90,59 @@ async def cmd_close(player: Player, args: str) -> None: await player.send(f"You close the {thing.name}.\r\n") +async def cmd_put(player: Player, args: str) -> None: + """Put an item into a container.""" + if not args.strip() or " in " not in args: + await player.send("Put what where? (put in )\r\n") + return + + # Parse "thing in container" + parts = args.strip().split(" in ", 1) + if len(parts) != 2: + await player.send("Put what where? (put in )\r\n") + return + + thing_name = parts[0].strip() + container_name = parts[1].strip() + + if not thing_name or not container_name: + await player.send("Put what where? (put in )\r\n") + return + + # Find thing in player's inventory + from mudlib.commands.things import _find_thing_in_inventory + + thing = _find_thing_in_inventory(thing_name, player) + if thing is None: + await player.send("You're not carrying that.\r\n") + return + + # Find container (on ground or in inventory) + container_obj = _find_container(container_name, player) + if container_obj is None: + await player.send("You don't see that here.\r\n") + return + + if not isinstance(container_obj, Container): + await player.send("That's not a container.\r\n") + return + + if thing is container_obj: + await player.send("You can't put something inside itself.\r\n") + return + + if container_obj.closed: + await player.send(f"The {container_obj.name} is closed.\r\n") + return + + if not container_obj.can_accept(thing): + await player.send(f"The {container_obj.name} is full.\r\n") + return + + thing.move_to(container_obj) + await player.send(f"You put the {thing.name} in the {container_obj.name}.\r\n") + + register(CommandDefinition("open", cmd_open)) register(CommandDefinition("close", cmd_close)) +register(CommandDefinition("put", cmd_put)) diff --git a/src/mudlib/commands/things.py b/src/mudlib/commands/things.py index f602ad7..945f4c0 100644 --- a/src/mudlib/commands/things.py +++ b/src/mudlib/commands/things.py @@ -51,11 +51,23 @@ def _format_thing_name(thing: Thing) -> str: async def cmd_get(player: Player, args: str) -> None: - """Pick up an item from the ground.""" + """Pick up an item from the ground or take from a container.""" if not args.strip(): await player.send("Get what?\r\n") return + # Check if this is "take/get X from Y" syntax + # Match " from " or "from " (at start) or " from" (at end) + args_lower = args.lower() + if ( + " from " in args_lower + or args_lower.startswith("from ") + or args_lower.endswith(" from") + or args_lower == "from" + ): + await _handle_take_from(player, args) + return + zone = player.location if zone is None or not isinstance(zone, Zone): await player.send("You are nowhere.\r\n") @@ -74,6 +86,54 @@ async def cmd_get(player: Player, args: str) -> None: await player.send(f"You pick up {thing.name}.\r\n") +async def _handle_take_from(player: Player, args: str) -> None: + """Handle 'take/get X from Y' to remove items from containers.""" + # Parse "thing from container" + parts = args.strip().split(" from ", 1) + thing_name = parts[0].strip() if len(parts) > 0 else "" + container_name = parts[1].strip() if len(parts) > 1 else "" + + if not thing_name or not container_name: + await player.send("Take what from where? (take from )\r\n") + return + + # Find container (on ground or in inventory) + from mudlib.commands.containers import _find_container + + container_obj = _find_container(container_name, player) + if container_obj is None: + await player.send("You don't see that here.\r\n") + return + + if not isinstance(container_obj, Container): + await player.send("That's not a container.\r\n") + return + + if container_obj.closed: + await player.send(f"The {container_obj.name} is closed.\r\n") + return + + # Find thing in container + thing = None + thing_name_lower = thing_name.lower() + for obj in container_obj.contents: + if not isinstance(obj, Thing): + continue + if obj.name.lower() == thing_name_lower: + thing = obj + break + if thing_name_lower in (a.lower() for a in obj.aliases): + thing = obj + break + + if thing is None: + await player.send(f"The {container_obj.name} doesn't contain that.\r\n") + return + + thing.move_to(player) + await player.send(f"You take the {thing.name} from the {container_obj.name}.\r\n") + + async def cmd_drop(player: Player, args: str) -> None: """Drop an item from inventory onto the ground.""" if not args.strip(): diff --git a/tests/test_put_take.py b/tests/test_put_take.py new file mode 100644 index 0000000..a070154 --- /dev/null +++ b/tests/test_put_take.py @@ -0,0 +1,381 @@ +"""Tests for put and take-from commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands import _registry +from mudlib.container import Container +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 + + +# --- cmd_put --- + + +@pytest.mark.asyncio +async def test_put_thing_in_container(player, test_zone, mock_writer): + """put moves thing from inventory to container.""" + from mudlib.commands.containers import cmd_put + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in chest") + + assert rock.location == chest + assert rock in chest.contents + assert rock not in player.contents + output = mock_writer.write.call_args_list[-1][0][0] + assert "put" in output.lower() and "rock" in output.lower() + assert "chest" in output.lower() + + +@pytest.mark.asyncio +async def test_put_no_args(player, mock_writer): + """put with no args gives usage message.""" + from mudlib.commands.containers import cmd_put + + await cmd_put(player, "") + output = mock_writer.write.call_args_list[-1][0][0] + assert "put" in output.lower() and "container" in output.lower() + + +@pytest.mark.asyncio +async def test_put_missing_container_arg(player, mock_writer): + """put with missing container argument gives error.""" + from mudlib.commands.containers import cmd_put + + _rock = Thing(name="rock", location=player) + await cmd_put(player, "rock in") + output = mock_writer.write.call_args_list[-1][0][0] + assert "put" in output.lower() and "container" in output.lower() + + +@pytest.mark.asyncio +async def test_put_thing_not_in_inventory(player, test_zone, mock_writer): + """put on thing not carried gives error.""" + from mudlib.commands.containers import cmd_put + + _chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + _rock = Thing(name="rock", location=test_zone, x=5, y=5) + + await cmd_put(player, "rock in chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "not carrying" in output.lower() or "aren't carrying" in output.lower() + + +@pytest.mark.asyncio +async def test_put_container_not_found(player, mock_writer): + """put into non-existent container gives error.""" + from mudlib.commands.containers import cmd_put + + _rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "don't see" in output.lower() + + +@pytest.mark.asyncio +async def test_put_container_closed(player, test_zone, mock_writer): + """put into closed container gives error.""" + from mudlib.commands.containers import cmd_put + + _chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=True, capacity=10 + ) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in chest") + assert rock.location == player # Should not have moved + output = mock_writer.write.call_args_list[-1][0][0] + assert "closed" in output.lower() + + +@pytest.mark.asyncio +async def test_put_container_full(player, test_zone, mock_writer): + """put into full container gives error.""" + from mudlib.commands.containers import cmd_put + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=1 + ) + # Fill the container + _other = Thing(name="other", location=chest) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in chest") + assert rock.location == player # Should not have moved + output = mock_writer.write.call_args_list[-1][0][0] + assert "full" in output.lower() + + +@pytest.mark.asyncio +async def test_put_target_not_container(player, test_zone, mock_writer): + """put into non-container thing gives error.""" + from mudlib.commands.containers import cmd_put + + _stick = Thing(name="stick", location=test_zone, x=5, y=5) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in stick") + assert rock.location == player # Should not have moved + output = mock_writer.write.call_args_list[-1][0][0] + assert "not a container" in output.lower() or "can't" in output.lower() + + +@pytest.mark.asyncio +async def test_put_matches_thing_alias(player, test_zone): + """put matches thing by alias.""" + from mudlib.commands.containers import cmd_put + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + rock = Thing(name="small rock", aliases=["rock", "pebble"], location=player) + + await cmd_put(player, "pebble in chest") + assert rock.location == chest + + +@pytest.mark.asyncio +async def test_put_matches_container_alias(player, test_zone): + """put matches container by alias.""" + from mudlib.commands.containers import cmd_put + + chest = Container( + name="wooden chest", + aliases=["chest", "box"], + location=test_zone, + x=5, + y=5, + closed=False, + capacity=10, + ) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in box") + assert rock.location == chest + + +@pytest.mark.asyncio +async def test_put_container_in_inventory(player, test_zone): + """put works with container in player inventory.""" + from mudlib.commands.containers import cmd_put + + box = Container(name="box", location=player, closed=False, capacity=10) + rock = Thing(name="rock", location=player) + + await cmd_put(player, "rock in box") + assert rock.location == box + + +@pytest.mark.asyncio +async def test_put_container_in_itself(player, test_zone, mock_writer): + """put container in itself gives error.""" + from mudlib.commands.containers import cmd_put + + box = Container(name="box", location=player, closed=False, capacity=10) + + await cmd_put(player, "box in box") + assert box.location == player # Should not have moved + output = mock_writer.write.call_args_list[-1][0][0] + assert "can't put" in output.lower() and "itself" in output.lower() + + +# --- cmd_get with "from" (take-from) --- + + +@pytest.mark.asyncio +async def test_take_from_container(player, test_zone, mock_writer): + """take from container moves thing to inventory.""" + from mudlib.commands.things import cmd_get + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + rock = Thing(name="rock", location=chest) + + await cmd_get(player, "rock from chest") + + assert rock.location == player + assert rock in player.contents + assert rock not in chest.contents + output = mock_writer.write.call_args_list[-1][0][0] + assert "take" in output.lower() and "rock" in output.lower() + assert "chest" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_no_args(player, mock_writer): + """take from with missing args gives usage message.""" + from mudlib.commands.things import cmd_get + + await cmd_get(player, "from") + output = mock_writer.write.call_args_list[-1][0][0] + assert "take" in output.lower() and "container" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_missing_container(player, test_zone, mock_writer): + """take from with missing container gives error.""" + from mudlib.commands.things import cmd_get + + _chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + + await cmd_get(player, "rock from") + output = mock_writer.write.call_args_list[-1][0][0] + assert "take" in output.lower() and "container" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_container_not_found(player, test_zone, mock_writer): + """take from non-existent container gives error.""" + from mudlib.commands.things import cmd_get + + await cmd_get(player, "rock from chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "don't see" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_closed_container(player, test_zone, mock_writer): + """take from closed container gives error.""" + from mudlib.commands.things import cmd_get + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=True, capacity=10 + ) + _rock = Thing(name="rock", location=chest) + + await cmd_get(player, "rock from chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "closed" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_thing_not_in_container(player, test_zone, mock_writer): + """take from container when thing not inside gives error.""" + from mudlib.commands.things import cmd_get + + _chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + + await cmd_get(player, "rock from chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "doesn't contain" in output.lower() or "not in" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_non_container(player, test_zone, mock_writer): + """take from non-container thing gives error.""" + from mudlib.commands.things import cmd_get + + _stick = Thing(name="stick", location=test_zone, x=5, y=5) + + await cmd_get(player, "rock from stick") + output = mock_writer.write.call_args_list[-1][0][0] + assert "not a container" in output.lower() or "can't" in output.lower() + + +@pytest.mark.asyncio +async def test_take_from_matches_thing_alias(player, test_zone): + """take from matches thing by alias.""" + from mudlib.commands.things import cmd_get + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=False, capacity=10 + ) + rock = Thing(name="small rock", aliases=["rock", "pebble"], location=chest) + + await cmd_get(player, "pebble from chest") + assert rock.location == player + + +@pytest.mark.asyncio +async def test_take_from_matches_container_alias(player, test_zone): + """take from matches container by alias.""" + from mudlib.commands.things import cmd_get + + chest = Container( + name="wooden chest", + aliases=["chest", "box"], + location=test_zone, + x=5, + y=5, + closed=False, + capacity=10, + ) + rock = Thing(name="rock", location=chest) + + await cmd_get(player, "rock from box") + assert rock.location == player + + +@pytest.mark.asyncio +async def test_take_from_container_in_inventory(player, test_zone): + """take from works with container in player inventory.""" + from mudlib.commands.things import cmd_get + + box = Container(name="box", location=player, closed=False, capacity=10) + rock = Thing(name="rock", location=box) + + await cmd_get(player, "rock from box") + assert rock.location == player + + +# --- command registration --- + + +def test_put_command_registered(): + """put command is registered.""" + import mudlib.commands.containers # noqa: F401 + + assert "put" in _registry