diff --git a/src/mudlib/commands/containers.py b/src/mudlib/commands/containers.py new file mode 100644 index 0000000..fa31459 --- /dev/null +++ b/src/mudlib/commands/containers.py @@ -0,0 +1,94 @@ +"""Open and close commands for containers.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.container import Container +from mudlib.player import Player +from mudlib.thing import Thing +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. + + 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 + + # 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 + + +async def cmd_open(player: Player, args: str) -> None: + """Open a container.""" + if not args.strip(): + await player.send("Open what?\r\n") + return + + thing = _find_container(args.strip(), player) + if thing is None: + await player.send("You don't see that here.\r\n") + return + + if not isinstance(thing, Container): + await player.send("You can't open that.\r\n") + return + + if not thing.closed: + await player.send("It's already open.\r\n") + return + + if thing.locked: + await player.send("It's locked.\r\n") + return + + thing.closed = False + await player.send(f"You open the {thing.name}.\r\n") + + +async def cmd_close(player: Player, args: str) -> None: + """Close a container.""" + if not args.strip(): + await player.send("Close what?\r\n") + return + + thing = _find_container(args.strip(), player) + if thing is None: + await player.send("You don't see that here.\r\n") + return + + if not isinstance(thing, Container): + await player.send("You can't close that.\r\n") + return + + if thing.closed: + await player.send("It's already closed.\r\n") + return + + thing.closed = True + await player.send(f"You close the {thing.name}.\r\n") + + +register(CommandDefinition("open", cmd_open)) +register(CommandDefinition("close", cmd_close)) diff --git a/src/mudlib/server.py b/src/mudlib/server.py index 55c758c..2e1483b 100644 --- a/src/mudlib/server.py +++ b/src/mudlib/server.py @@ -31,8 +31,6 @@ from mudlib.effects import clear_expired from mudlib.if_session import broadcast_to_spectators from mudlib.mob_ai import process_mobs from mudlib.mobs import load_mob_templates, mob_templates -from mudlib.thing import Thing -from mudlib.things import load_thing_templates, spawn_thing, thing_templates from mudlib.player import Player, players from mudlib.resting import process_resting from mudlib.store import ( diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py new file mode 100644 index 0000000..f578105 --- /dev/null +++ b/src/mudlib/zones.py @@ -0,0 +1,30 @@ +"""Zone registry and loading.""" + +from __future__ import annotations + +from mudlib.zone import Zone + +# Module-level zone registry +zone_registry: dict[str, Zone] = {} + + +def register_zone(name: str, zone: Zone) -> None: + """Register a zone by name. + + Args: + name: Unique name for the zone + zone: Zone instance to register + """ + zone_registry[name] = zone + + +def get_zone(name: str) -> Zone | None: + """Look up a zone by name. + + Args: + name: Zone name to look up + + Returns: + Zone instance if found, None otherwise + """ + return zone_registry.get(name) diff --git a/tests/test_container_display.py b/tests/test_container_display.py new file mode 100644 index 0000000..6ff9050 --- /dev/null +++ b/tests/test_container_display.py @@ -0,0 +1,152 @@ +"""Tests for container state display in look and inventory commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +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 + + +# --- look command container display --- + + +@pytest.mark.asyncio +async def test_look_shows_closed_container(player, test_zone, mock_writer): + """look shows closed containers with (closed) suffix.""" + from mudlib.commands.look import cmd_look + + Container(name="chest", location=test_zone, x=5, y=5, closed=True) + await cmd_look(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "chest (closed)" in output + + +@pytest.mark.asyncio +async def test_look_shows_open_empty_container(player, test_zone, mock_writer): + """look shows open empty containers with (open, empty) suffix.""" + from mudlib.commands.look import cmd_look + + Container(name="chest", location=test_zone, x=5, y=5, closed=False) + await cmd_look(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "chest (open, empty)" in output + + +@pytest.mark.asyncio +async def test_look_shows_open_container_with_contents( + player, test_zone, mock_writer +): + """look shows open containers with their contents.""" + from mudlib.commands.look import cmd_look + + chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False) + Thing(name="rock", location=chest) + Thing(name="coin", location=chest) + await cmd_look(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "chest (open, containing: rock, coin)" in output + + +@pytest.mark.asyncio +async def test_look_shows_regular_things_unchanged(player, test_zone, mock_writer): + """look shows regular Things without container suffixes.""" + from mudlib.commands.look import cmd_look + + Thing(name="rock", location=test_zone, x=5, y=5) + await cmd_look(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "On the ground: rock" in output + assert "(closed)" not in output + assert "(open" not in output + + +# --- inventory command container display --- + + +@pytest.mark.asyncio +async def test_inventory_shows_closed_container(player, mock_writer): + """inventory shows closed containers with (closed) suffix.""" + from mudlib.commands.things import cmd_inventory + + Container(name="sack", location=player, closed=True) + await cmd_inventory(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "sack (closed)" in output + + +@pytest.mark.asyncio +async def test_inventory_shows_open_empty_container(player, mock_writer): + """inventory shows open empty containers with (open, empty) suffix.""" + from mudlib.commands.things import cmd_inventory + + Container(name="sack", location=player, closed=False) + await cmd_inventory(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "sack (open, empty)" in output + + +@pytest.mark.asyncio +async def test_inventory_shows_container_with_contents(player, mock_writer): + """inventory shows open containers with their contents.""" + from mudlib.commands.things import cmd_inventory + + sack = Container(name="sack", location=player, closed=False) + Thing(name="rock", location=sack) + Thing(name="gem", location=sack) + await cmd_inventory(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert "sack (open, containing: rock, gem)" in output + + +@pytest.mark.asyncio +async def test_inventory_shows_regular_things_unchanged(player, mock_writer): + """inventory shows regular Things without container suffixes.""" + from mudlib.commands.things import cmd_inventory + + Thing(name="rock", location=player) + await cmd_inventory(player, "") + output = "".join(call[0][0] for call in mock_writer.write.call_args_list) + assert " rock\r\n" in output + assert "(closed)" not in output + assert "(open" not in output diff --git a/tests/test_open_close.py b/tests/test_open_close.py new file mode 100644 index 0000000..8fc3457 --- /dev/null +++ b/tests/test_open_close.py @@ -0,0 +1,226 @@ +"""Tests for open and close 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_open --- + + +@pytest.mark.asyncio +async def test_open_container_on_ground(player, test_zone, mock_writer): + """open finds container on ground and sets closed=False.""" + from mudlib.commands.containers import cmd_open + + chest = Container(name="chest", location=test_zone, x=5, y=5, closed=True) + await cmd_open(player, "chest") + assert chest.closed is False + output = mock_writer.write.call_args_list[-1][0][0] + assert "open" in output.lower() and "chest" in output.lower() + + +@pytest.mark.asyncio +async def test_open_container_in_inventory(player, test_zone, mock_writer): + """open finds container in player inventory and sets closed=False.""" + from mudlib.commands.containers import cmd_open + + box = Container(name="box", location=player, closed=True) + await cmd_open(player, "box") + assert box.closed is False + output = mock_writer.write.call_args_list[-1][0][0] + assert "open" in output.lower() and "box" in output.lower() + + +@pytest.mark.asyncio +async def test_open_no_args(player, mock_writer): + """open with no arguments gives usage hint.""" + from mudlib.commands.containers import cmd_open + + await cmd_open(player, "") + output = mock_writer.write.call_args_list[-1][0][0] + assert "open what" in output.lower() or "what" in output.lower() + + +@pytest.mark.asyncio +async def test_open_already_open(player, test_zone, mock_writer): + """open on already-open container gives feedback.""" + from mudlib.commands.containers import cmd_open + + chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False) + await cmd_open(player, "chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "already open" in output.lower() + + +@pytest.mark.asyncio +async def test_open_locked_container(player, test_zone, mock_writer): + """open on locked container gives feedback.""" + from mudlib.commands.containers import cmd_open + + chest = Container( + name="chest", location=test_zone, x=5, y=5, closed=True, locked=True + ) + await cmd_open(player, "chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "locked" in output.lower() + # Container should still be closed + assert chest.closed is True + + +@pytest.mark.asyncio +async def test_open_not_found(player, test_zone, mock_writer): + """open on non-existent thing gives feedback.""" + from mudlib.commands.containers import cmd_open + + await cmd_open(player, "chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "don't see" in output.lower() + + +@pytest.mark.asyncio +async def test_open_matches_aliases(player, test_zone): + """open matches container aliases.""" + from mudlib.commands.containers import cmd_open + + chest = Container( + name="wooden chest", + aliases=["chest", "box"], + location=test_zone, + x=5, + y=5, + closed=True, + ) + await cmd_open(player, "box") + assert chest.closed is False + + +@pytest.mark.asyncio +async def test_open_non_container(player, test_zone, mock_writer): + """open on non-container thing gives feedback.""" + from mudlib.commands.containers import cmd_open + + rock = Thing(name="rock", location=test_zone, x=5, y=5) + await cmd_open(player, "rock") + output = mock_writer.write.call_args_list[-1][0][0] + assert "can't open" in output.lower() + + +# --- cmd_close --- + + +@pytest.mark.asyncio +async def test_close_container_on_ground(player, test_zone, mock_writer): + """close finds container on ground and sets closed=True.""" + from mudlib.commands.containers import cmd_close + + chest = Container(name="chest", location=test_zone, x=5, y=5, closed=False) + await cmd_close(player, "chest") + assert chest.closed is True + output = mock_writer.write.call_args_list[-1][0][0] + assert "close" in output.lower() and "chest" in output.lower() + + +@pytest.mark.asyncio +async def test_close_container_in_inventory(player, test_zone, mock_writer): + """close finds container in inventory and sets closed=True.""" + from mudlib.commands.containers import cmd_close + + box = Container(name="box", location=player, closed=False) + await cmd_close(player, "box") + assert box.closed is True + output = mock_writer.write.call_args_list[-1][0][0] + assert "close" in output.lower() and "box" in output.lower() + + +@pytest.mark.asyncio +async def test_close_already_closed(player, test_zone, mock_writer): + """close on already-closed container gives feedback.""" + from mudlib.commands.containers import cmd_close + + chest = Container(name="chest", location=test_zone, x=5, y=5, closed=True) + await cmd_close(player, "chest") + output = mock_writer.write.call_args_list[-1][0][0] + assert "already closed" in output.lower() + + +@pytest.mark.asyncio +async def test_close_no_args(player, mock_writer): + """close with no arguments gives usage hint.""" + from mudlib.commands.containers import cmd_close + + await cmd_close(player, "") + output = mock_writer.write.call_args_list[-1][0][0] + assert "close what" in output.lower() or "what" in output.lower() + + +@pytest.mark.asyncio +async def test_close_non_container(player, test_zone, mock_writer): + """close on non-container thing gives feedback.""" + from mudlib.commands.containers import cmd_close + + rock = Thing(name="rock", location=test_zone, x=5, y=5) + await cmd_close(player, "rock") + output = mock_writer.write.call_args_list[-1][0][0] + assert "can't close" in output.lower() + + +# --- command registration --- + + +def test_open_command_registered(): + """open command is registered.""" + import mudlib.commands.containers # noqa: F401 + + assert "open" in _registry + + +def test_close_command_registered(): + """close command is registered.""" + import mudlib.commands.containers # noqa: F401 + + assert "close" in _registry diff --git a/tests/test_zone_registry.py b/tests/test_zone_registry.py new file mode 100644 index 0000000..d61ae56 --- /dev/null +++ b/tests/test_zone_registry.py @@ -0,0 +1,62 @@ +"""Tests for zone registry.""" + +import pytest + +from mudlib.zone import Zone +from mudlib.zones import get_zone, register_zone, zone_registry + + +@pytest.fixture(autouse=True) +def clear_registry(): + """Clear zone registry before each test.""" + zone_registry.clear() + yield + zone_registry.clear() + + +def test_register_zone(): + """Register a zone by name.""" + zone = Zone(name="test_zone", width=10, height=10, terrain=[], toroidal=False) + register_zone("test_zone", zone) + + assert "test_zone" in zone_registry + assert zone_registry["test_zone"] is zone + + +def test_get_zone(): + """Look up a zone by name.""" + zone = Zone(name="test_zone", width=10, height=10, terrain=[], toroidal=False) + register_zone("test_zone", zone) + + retrieved = get_zone("test_zone") + assert retrieved is zone + + +def test_get_zone_unknown(): + """Get None for unknown zone name.""" + result = get_zone("nonexistent") + assert result is None + + +def test_register_multiple_zones(): + """Register multiple zones.""" + zone1 = Zone(name="zone1", width=10, height=10, terrain=[], toroidal=False) + zone2 = Zone(name="zone2", width=20, height=15, terrain=[], toroidal=True) + + register_zone("zone1", zone1) + register_zone("zone2", zone2) + + assert len(zone_registry) == 2 + assert get_zone("zone1") is zone1 + assert get_zone("zone2") is zone2 + + +def test_overwrite_zone(): + """Registering same name twice overwrites.""" + zone1 = Zone(name="zone", width=10, height=10, terrain=[], toroidal=False) + zone2 = Zone(name="zone", width=20, height=20, terrain=[], toroidal=False) + + register_zone("zone", zone1) + register_zone("zone", zone2) + + assert get_zone("zone") is zone2