From 7342a70ba26ce13c47eba361e57d7fe40c78db53 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 17:42:52 -0500 Subject: [PATCH] Add furnish and unfurnish commands --- src/mudlib/commands/furnish.py | 92 ++++++++++++ src/mudlib/server.py | 1 + tests/test_furnish.py | 263 +++++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+) create mode 100644 src/mudlib/commands/furnish.py create mode 100644 tests/test_furnish.py diff --git a/src/mudlib/commands/furnish.py b/src/mudlib/commands/furnish.py new file mode 100644 index 0000000..26b1a03 --- /dev/null +++ b/src/mudlib/commands/furnish.py @@ -0,0 +1,92 @@ +"""Furnish and unfurnish commands — place/remove furniture in home zones.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.housing import save_home_zone +from mudlib.player import Player +from mudlib.targeting import find_in_inventory, find_thing_on_tile +from mudlib.zone import Zone + + +async def cmd_furnish(player: Player, args: str) -> None: + """Place an item from inventory as furniture in your home zone. + + Usage: + furnish + """ + # Validate arguments + if not args.strip(): + await player.send("Usage: furnish \r\n") + return + + # Check that player is in their home zone + zone = player.location + if not isinstance(zone, Zone) or zone.name != player.home_zone: + await player.send("You can only furnish items in your home zone.\r\n") + return + + # Find item in inventory + item_name = args.strip() + thing = find_in_inventory(item_name, player) + + if thing is None: + await player.send(f"You don't have '{item_name}'.\r\n") + return + + # Place item at player's position + thing.move_to(zone, x=player.x, y=player.y) + + # Save the zone + save_home_zone(player.name, zone) + + await player.send(f"You place the {thing.name} here.\r\n") + + +async def cmd_unfurnish(player: Player, args: str) -> None: + """Pick up furniture from your home zone into inventory. + + Usage: + unfurnish + """ + # Validate arguments + if not args.strip(): + await player.send("Usage: unfurnish \r\n") + return + + # Check that player is in their home zone + zone = player.location + if not isinstance(zone, Zone) or zone.name != player.home_zone: + await player.send("You can only unfurnish items in your home zone.\r\n") + return + + # Find furniture at player's position + item_name = args.strip() + thing = find_thing_on_tile(item_name, zone, player.x, player.y) + + if thing is None: + await player.send(f"You don't see '{item_name}' here.\r\n") + return + + # Pick up the item + thing.move_to(player) + + # Save the zone + save_home_zone(player.name, zone) + + await player.send(f"You pick up the {thing.name}.\r\n") + + +register( + CommandDefinition( + "furnish", + cmd_furnish, + help="Place an item from inventory as furniture in your home zone.", + ) +) + +register( + CommandDefinition( + "unfurnish", + cmd_unfurnish, + help="Pick up furniture from your home zone into inventory.", + ) +) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index eb9a086..e1b4eb2 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -21,6 +21,7 @@ import mudlib.commands.describe import mudlib.commands.edit import mudlib.commands.examine import mudlib.commands.fly +import mudlib.commands.furnish import mudlib.commands.help import mudlib.commands.home import mudlib.commands.look diff --git a/tests/test_furnish.py b/tests/test_furnish.py new file mode 100644 index 0000000..6d27e56 --- /dev/null +++ b/tests/test_furnish.py @@ -0,0 +1,263 @@ +"""Tests for furnish and unfurnish commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.housing import init_housing +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.zone import Zone +from mudlib.zones import register_zone, zone_registry + + +@pytest.fixture(autouse=True) +def _clean_registries(): + """Clear zone registry between tests.""" + saved = dict(zone_registry) + zone_registry.clear() + yield + zone_registry.clear() + zone_registry.update(saved) + + +def _make_zone(name="overworld", width=20, height=20): + """Create a zone for testing.""" + terrain = [["." for _ in range(width)] for _ in range(height)] + zone = Zone( + name=name, + description=name, + width=width, + height=height, + terrain=terrain, + toroidal=True, + ) + register_zone(name, zone) + return zone + + +def _make_player(name="tester", zone=None, x=5, y=5): + """Create a player with mock writer.""" + mock_writer = MagicMock() + mock_writer.write = MagicMock() + mock_writer.drain = AsyncMock() + return Player(name=name, location=zone, x=x, y=y, writer=mock_writer) + + +@pytest.mark.asyncio +async def test_furnish_places_item(tmp_path): + """furnish moves an item from inventory to the zone at player position.""" + from mudlib.commands.furnish import cmd_furnish + + init_housing(tmp_path) + + # Create home zone and player + home_zone = _make_zone("home:alice", width=9, height=9) + player = _make_player("alice", zone=home_zone, x=4, y=4) + player.home_zone = "home:alice" + + # Give player a chair + chair = Thing(name="chair", description="A wooden chair") + chair.move_to(player) + + # Furnish the chair + await cmd_furnish(player, "chair") + + # Chair should be in the zone at player position + assert chair.location is home_zone + assert chair.x == 4 + assert chair.y == 4 + assert chair not in player.contents + + # Player should get feedback + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "chair" in output.lower() + + +@pytest.mark.asyncio +async def test_furnish_not_in_home_zone(): + """furnish fails if player is not in their home zone.""" + from mudlib.commands.furnish import cmd_furnish + + overworld = _make_zone("overworld") + player = _make_player("alice", zone=overworld) + player.home_zone = "home:alice" + + chair = Thing(name="chair") + chair.move_to(player) + + await cmd_furnish(player, "chair") + + # Chair should still be in inventory + assert chair.location is player + assert chair in player.contents + + # Player should get error message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "home zone" in output.lower() + + +@pytest.mark.asyncio +async def test_furnish_item_not_in_inventory(): + """furnish fails if item is not in player inventory.""" + from mudlib.commands.furnish import cmd_furnish + + home_zone = _make_zone("home:alice", width=9, height=9) + player = _make_player("alice", zone=home_zone) + player.home_zone = "home:alice" + + await cmd_furnish(player, "chair") + + # Player should get error message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "don't have" in output.lower() or "not carrying" in output.lower() + + +@pytest.mark.asyncio +async def test_furnish_no_args(): + """furnish fails with usage message if no args provided.""" + from mudlib.commands.furnish import cmd_furnish + + home_zone = _make_zone("home:alice", width=9, height=9) + player = _make_player("alice", zone=home_zone) + player.home_zone = "home:alice" + + await cmd_furnish(player, "") + + # Player should get usage message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "usage" in output.lower() or "furnish" in output.lower() + + +@pytest.mark.asyncio +async def test_unfurnish_picks_up_item(tmp_path): + """unfurnish moves furniture from zone to player inventory.""" + from mudlib.commands.furnish import cmd_unfurnish + + init_housing(tmp_path) + + # Create home zone with furniture + home_zone = _make_zone("home:bob", width=9, height=9) + player = _make_player("bob", zone=home_zone, x=4, y=4) + player.home_zone = "home:bob" + + # Place a table at player position + table = Thing(name="table", description="A wooden table") + table.move_to(home_zone, x=4, y=4) + + # Unfurnish the table + await cmd_unfurnish(player, "table") + + # Table should be in inventory + assert table.location is player + assert table in player.contents + assert table not in home_zone._contents + + # Player should get feedback + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "table" in output.lower() + + +@pytest.mark.asyncio +async def test_unfurnish_not_in_home_zone(): + """unfurnish fails if player is not in their home zone.""" + from mudlib.commands.furnish import cmd_unfurnish + + overworld = _make_zone("overworld") + player = _make_player("bob", zone=overworld) + player.home_zone = "home:bob" + + # Place a table + table = Thing(name="table") + table.move_to(overworld, x=5, y=5) + + await cmd_unfurnish(player, "table") + + # Table should still be on ground + assert table.location is overworld + assert table not in player.contents + + # Player should get error message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "home zone" in output.lower() + + +@pytest.mark.asyncio +async def test_unfurnish_nothing_at_position(): + """unfurnish fails if no matching furniture at player position.""" + from mudlib.commands.furnish import cmd_unfurnish + + home_zone = _make_zone("home:bob", width=9, height=9) + player = _make_player("bob", zone=home_zone, x=4, y=4) + player.home_zone = "home:bob" + + # No furniture at position + await cmd_unfurnish(player, "table") + + # Player should get error message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert ( + "no" in output.lower() + or "don't see" in output.lower() + or "can't find" in output.lower() + ) + + +@pytest.mark.asyncio +async def test_unfurnish_no_args(): + """unfurnish fails with usage message if no args provided.""" + from mudlib.commands.furnish import cmd_unfurnish + + home_zone = _make_zone("home:bob", width=9, height=9) + player = _make_player("bob", zone=home_zone) + player.home_zone = "home:bob" + + await cmd_unfurnish(player, "") + + # Player should get usage message + player.writer.write.assert_called() + output = "".join(call.args[0] for call in player.writer.write.call_args_list) + assert "usage" in output.lower() or "unfurnish" in output.lower() + + +@pytest.mark.asyncio +async def test_furnish_saves_zone(tmp_path): + """furnish persists furniture to the zone TOML file.""" + import tomllib + + from mudlib.commands.furnish import cmd_furnish + + init_housing(tmp_path) + + # Create home zone and player + home_zone = _make_zone("home:charlie", width=9, height=9) + player = _make_player("charlie", zone=home_zone, x=4, y=4) + player.home_zone = "home:charlie" + + # Give player a lamp + lamp = Thing(name="lamp", description="A brass lamp") + lamp.move_to(player) + + # Furnish it + await cmd_furnish(player, "lamp") + + # Check TOML file + zone_file = tmp_path / "charlie.toml" + assert zone_file.exists() + + with open(zone_file, "rb") as f: + data = tomllib.load(f) + + # Should have furniture entry + furniture = data.get("furniture", []) + assert len(furniture) == 1 + assert furniture[0]["template"] == "lamp" + assert furniture[0]["x"] == 4 + assert furniture[0]["y"] == 4