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:
parent
9437728435
commit
7c12bf3318
3 changed files with 396 additions and 0 deletions
80
src/mudlib/commands/things.py
Normal file
80
src/mudlib/commands/things.py
Normal 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))
|
||||
|
|
@ -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
|
||||
|
|
|
|||
290
tests/test_get_drop.py
Normal file
290
tests/test_get_drop.py
Normal 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
|
||||
Loading…
Reference in a new issue