Add Object.move_to(), get and drop commands

Object.move_to() handles containment transfer: removes from old location's
contents, updates location pointer and coordinates, adds to new location.
get/drop commands use move_to to transfer Things between zone and inventory.
Supports name and alias matching for item lookup.
This commit is contained in:
Jared Miller 2026-02-11 19:57:38 -05:00
parent 9437728435
commit 7c12bf3318
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C
3 changed files with 396 additions and 0 deletions

View file

@ -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))

View file

@ -32,6 +32,32 @@ class Object:
"""Everything whose location is this object.""" """Everything whose location is this object."""
return list(self._contents) 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: def can_accept(self, obj: Object) -> bool:
"""Whether this object accepts obj as contents. Default: no.""" """Whether this object accepts obj as contents. Default: no."""
return False return False

290
tests/test_get_drop.py Normal file
View file

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