From 4878f391240d83100573d169a79409c8a01cdf2d Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 01:28:28 -0500 Subject: [PATCH] Add container grammar with get-all and targeting support - Update _find_container to use targeting module (prefix + ordinal) - Update cmd_put to use find_in_inventory directly - Add 'get all from ' support with portable item filtering - Add comprehensive tests for all container grammar features --- src/mudlib/commands/containers.py | 28 ++---- src/mudlib/commands/things.py | 18 ++++ tests/test_container_grammar.py | 143 ++++++++++++++++++++++++++++++ 3 files changed, 168 insertions(+), 21 deletions(-) create mode 100644 tests/test_container_grammar.py diff --git a/src/mudlib/commands/containers.py b/src/mudlib/commands/containers.py index 951ab2d..5cfaecd 100644 --- a/src/mudlib/commands/containers.py +++ b/src/mudlib/commands/containers.py @@ -3,6 +3,7 @@ from mudlib.commands import CommandDefinition, register from mudlib.container import Container from mudlib.player import Player +from mudlib.targeting import find_in_inventory, find_thing_on_tile from mudlib.thing import Thing from mudlib.zone import Zone @@ -10,34 +11,21 @@ from mudlib.zone import Zone def _find_container(name: str, player: Player) -> Container | Thing | None: """Find a thing by name in inventory first, then on ground. + Uses targeting module for prefix matching and ordinal support. Returns Thing if found (caller must check if it's a Container). Returns None if not found. """ - 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 name_lower in (a.lower() for a in obj.aliases): - return obj + result = find_in_inventory(name, player) + if result is not None: + return result # Check ground at player's position zone = player.location if zone is None or not isinstance(zone, Zone): return None - for obj in zone.contents_at(player.x, player.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 + return find_thing_on_tile(name, zone, player.x, player.y) async def cmd_open(player: Player, args: str) -> None: @@ -110,9 +98,7 @@ async def cmd_put(player: Player, args: str) -> None: return # Find thing in player's inventory - from mudlib.commands.things import _find_thing_in_inventory - - thing = _find_thing_in_inventory(thing_name, player) + thing = find_in_inventory(thing_name, player) if thing is None: await player.send("You're not carrying that.\r\n") return diff --git a/src/mudlib/commands/things.py b/src/mudlib/commands/things.py index 2749598..5ae49e2 100644 --- a/src/mudlib/commands/things.py +++ b/src/mudlib/commands/things.py @@ -104,6 +104,24 @@ async def _handle_take_from(player: Player, args: str) -> None: await player.send(f"The {container_obj.name} is closed.\r\n") return + # Handle "get all from container" + if thing_name.lower() == "all": + container_things = [ + obj for obj in container_obj.contents if isinstance(obj, Thing) + ] + portable_things = [t for t in container_things if player.can_accept(t)] + + if not portable_things: + msg = f"There's nothing in the {container_obj.name} to take.\r\n" + await player.send(msg) + return + + for thing in portable_things: + thing.move_to(player) + msg = f"You take the {thing.name} from the {container_obj.name}.\r\n" + await player.send(msg) + return + # Find thing in container using targeting (supports prefix and ordinals) from mudlib.targeting import resolve_target diff --git a/tests/test_container_grammar.py b/tests/test_container_grammar.py new file mode 100644 index 0000000..59dcaef --- /dev/null +++ b/tests/test_container_grammar.py @@ -0,0 +1,143 @@ +"""Tests for container grammar with targeting and get-all support.""" + +import pytest + +from mudlib.commands.containers import cmd_open, cmd_put +from mudlib.commands.things import cmd_get +from mudlib.container import Container +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.zone import Zone + + +@pytest.mark.asyncio +async def test_get_from_container_basic(mock_writer): + """Test basic 'get item from container' command.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=chest) + + await cmd_open(player, "chest") + await cmd_get(player, "sword from chest") + + assert sword.location is player + + +@pytest.mark.asyncio +async def test_get_from_container_prefix_match_item(mock_writer): + """Test 'get sw from chest' — prefix match item in container.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=chest) + + await cmd_open(player, "chest") + await cmd_get(player, "sw from chest") + + assert sword.location is player + + +@pytest.mark.asyncio +async def test_get_from_container_ordinal(mock_writer): + """Test 'get 2.sword from chest' — ordinal in container.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword1 = Thing(name="sword", location=chest) + sword2 = Thing(name="sword", location=chest) + + await cmd_open(player, "chest") + await cmd_get(player, "2.sword from chest") + + assert sword2.location is player + assert sword1.location is chest + + +@pytest.mark.asyncio +async def test_get_all_from_container(mock_writer): + """Test 'get all from chest' — take everything.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=chest) + shield = Thing(name="shield", location=chest) + helmet = Thing(name="helmet", location=chest) + + await cmd_open(player, "chest") + await cmd_get(player, "all from chest") + + assert sword.location is player + assert shield.location is player + assert helmet.location is player + + +@pytest.mark.asyncio +async def test_get_all_from_empty_container(mock_writer): + """Test 'get all from chest' when empty — no items moved.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + + await cmd_open(player, "chest") + await cmd_get(player, "all from chest") + + # Verify no items were added to inventory + assert len([obj for obj in player.contents if isinstance(obj, Thing)]) == 0 + assert chest.location is zone # chest should remain on ground + + +@pytest.mark.asyncio +async def test_put_in_container_prefix_match(mock_writer): + """Test 'put sw in chest' — prefix match in inventory for put.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=player) + + await cmd_open(player, "chest") + await cmd_put(player, "sw in chest") + + assert sword.location is chest + + +@pytest.mark.asyncio +async def test_open_container_prefix_match(mock_writer): + """Test 'open che' — prefix match container name.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone, closed=True) + + await cmd_open(player, "che") + + assert not chest.closed + + +@pytest.mark.asyncio +async def test_get_from_container_prefix_match_container(mock_writer): + """Test 'get sword from che' — prefix match container name.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=chest) + + await cmd_open(player, "chest") + await cmd_get(player, "sword from che") + + assert sword.location is player + + +@pytest.mark.asyncio +async def test_get_all_from_container_skips_non_portable(mock_writer): + """Test 'get all from chest' skips items player can't carry.""" + zone = Zone(name="test", width=10, height=10) + player = Player(name="test", x=5, y=5, location=zone, writer=mock_writer) + chest = Container(name="chest", x=5, y=5, location=zone) + sword = Thing(name="sword", location=chest, portable=True) + anvil = Thing(name="anvil", location=chest, portable=False) + + await cmd_open(player, "chest") + await cmd_get(player, "all from chest") + + assert sword.location is player + assert anvil.location is chest