From 71f4ae4ec435112f75917b5efd4bc390bcb35976 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 11:57:26 -0500 Subject: [PATCH] Add builder commands @goto, @dig, @save, and @place These commands enable runtime world editing: - @goto teleports to a named zone's spawn point - @dig creates a new blank zone with specified dimensions - @save exports the current zone to TOML - @place spawns a thing from templates at player position --- src/mudlib/commands/build.py | 116 ++++++++++++++++ tests/test_build_commands.py | 253 +++++++++++++++++++++++++++++++++++ 2 files changed, 369 insertions(+) create mode 100644 src/mudlib/commands/build.py create mode 100644 tests/test_build_commands.py diff --git a/src/mudlib/commands/build.py b/src/mudlib/commands/build.py new file mode 100644 index 0000000..7687020 --- /dev/null +++ b/src/mudlib/commands/build.py @@ -0,0 +1,116 @@ +"""Builder commands for world editing.""" + +from pathlib import Path + +from mudlib.commands import CommandDefinition, register +from mudlib.export import export_zone_to_file +from mudlib.player import Player +from mudlib.things import spawn_thing, thing_templates +from mudlib.zone import Zone +from mudlib.zones import get_zone, register_zone + +# Content directory, set during server startup +_content_dir: Path | None = None + + +def set_content_dir(path: Path) -> None: + """Set the content directory for saving zones.""" + global _content_dir + _content_dir = path + + +async def cmd_goto(player: Player, args: str) -> None: + """Teleport to a named zone's spawn point.""" + zone_name = args.strip() + if not zone_name: + await player.send("Usage: @goto \r\n") + return + + target = get_zone(zone_name) + if target is None: + await player.send(f"Zone '{zone_name}' not found.\r\n") + return + + player.move_to(target, x=target.spawn_x, y=target.spawn_y) + await player.send(f"Teleported to {zone_name}.\r\n") + + from mudlib.commands.look import cmd_look + + await cmd_look(player, "") + + +async def cmd_dig(player: Player, args: str) -> None: + """Create a new blank zone and teleport there.""" + parts = args.strip().split() + if len(parts) != 3: + await player.send("Usage: @dig \r\n") + return + + name = parts[0] + try: + width = int(parts[1]) + height = int(parts[2]) + except ValueError: + await player.send("Width and height must be numbers.\r\n") + return + + if get_zone(name) is not None: + await player.send(f"Zone '{name}' already exists.\r\n") + return + + terrain = [["." for _ in range(width)] for _ in range(height)] + zone = Zone( + name=name, + width=width, + height=height, + terrain=terrain, + toroidal=False, + ) + register_zone(name, zone) + + player.move_to(zone, x=0, y=0) + await player.send(f"Created zone '{name}' ({width}x{height}).\r\n") + + from mudlib.commands.look import cmd_look + + await cmd_look(player, "") + + +async def cmd_save(player: Player, args: str) -> None: + """Save the current zone to its TOML file.""" + zone = player.location + if not isinstance(zone, Zone): + await player.send("You're not in a zone.\r\n") + return + + if _content_dir is None: + await player.send("Content directory not configured.\r\n") + return + + zones_dir = _content_dir / "zones" + zones_dir.mkdir(parents=True, exist_ok=True) + path = zones_dir / f"{zone.name}.toml" + export_zone_to_file(zone, path) + await player.send(f"Zone '{zone.name}' saved to {path.name}.\r\n") + + +async def cmd_place(player: Player, args: str) -> None: + """Place a thing from templates at the player's position.""" + thing_name = args.strip() + if not thing_name: + await player.send("Usage: @place \r\n") + return + + template = thing_templates.get(thing_name) + if template is None: + await player.send(f"Thing template '{thing_name}' not found.\r\n") + return + + spawn_thing(template, player.location, x=player.x, y=player.y) + await player.send(f"Placed {thing_name} at ({player.x}, {player.y}).\r\n") + + +register(CommandDefinition("@goto", cmd_goto, help="Teleport to a zone")) +register(CommandDefinition("@dig", cmd_dig, help="Create a new zone")) +register(CommandDefinition("@save", cmd_save, help="Save current zone")) +register(CommandDefinition("@place", cmd_place, help="Place a thing")) diff --git a/tests/test_build_commands.py b/tests/test_build_commands.py new file mode 100644 index 0000000..7e2151f --- /dev/null +++ b/tests/test_build_commands.py @@ -0,0 +1,253 @@ +"""Tests for builder commands.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.player import Player, players +from mudlib.zone import Zone +from mudlib.zones import get_zone, register_zone, zone_registry + + +@pytest.fixture(autouse=True) +def clear_state(): + players.clear() + zone_registry.clear() + yield + players.clear() + zone_registry.clear() + + +@pytest.fixture +def zone(): + terrain = [["." for _ in range(10)] for _ in range(10)] + z = Zone(name="hub", width=10, height=10, terrain=terrain) + register_zone("hub", z) + return z + + +@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 player(zone, mock_writer, mock_reader): + p = Player( + name="builder", + x=5, + y=5, + writer=mock_writer, + reader=mock_reader, + location=zone, + ) + zone._contents.append(p) + players["builder"] = p + return p + + +# --- @goto --- + + +@pytest.mark.asyncio +async def test_goto_existing_zone(player): + """@goto teleports player to named zone's spawn point.""" + from mudlib.commands.build import cmd_goto + + target = Zone( + name="forest", + width=5, + height=5, + terrain=[["." for _ in range(5)] for _ in range(5)], + spawn_x=2, + spawn_y=3, + ) + register_zone("forest", target) + + await cmd_goto(player, "forest") + + assert player.location is target + assert player.x == 2 + assert player.y == 3 + + +@pytest.mark.asyncio +async def test_goto_nonexistent_zone(player, mock_writer): + """@goto with unknown zone name shows error.""" + from mudlib.commands.build import cmd_goto + + await cmd_goto(player, "nowhere") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[-1][0][0] + assert "not found" in written.lower() or "no zone" in written.lower() + + +@pytest.mark.asyncio +async def test_goto_no_args(player, mock_writer): + """@goto without arguments shows usage.""" + from mudlib.commands.build import cmd_goto + + await cmd_goto(player, "") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[0][0][0] + assert "usage" in written.lower() or "goto" in written.lower() + + +# --- @dig --- + + +@pytest.mark.asyncio +async def test_dig_creates_zone(player): + """@dig creates a new zone and teleports player there.""" + from mudlib.commands.build import cmd_dig + + await cmd_dig(player, "cave 8 6") + + new_zone = get_zone("cave") + assert new_zone is not None + assert new_zone.width == 8 + assert new_zone.height == 6 + assert player.location is new_zone + assert player.x == 0 + assert player.y == 0 + + +@pytest.mark.asyncio +async def test_dig_creates_terrain(player): + """@dig creates terrain filled with '.' tiles.""" + from mudlib.commands.build import cmd_dig + + await cmd_dig(player, "mine 5 4") + + new_zone = get_zone("mine") + assert new_zone is not None + assert len(new_zone.terrain) == 4 + assert len(new_zone.terrain[0]) == 5 + assert all(tile == "." for row in new_zone.terrain for tile in row) + + +@pytest.mark.asyncio +async def test_dig_zone_not_toroidal(player): + """@dig creates bounded (non-toroidal) zones.""" + from mudlib.commands.build import cmd_dig + + await cmd_dig(player, "room 3 3") + + new_zone = get_zone("room") + assert new_zone is not None + assert new_zone.toroidal is False + + +@pytest.mark.asyncio +async def test_dig_existing_name(player, mock_writer): + """@dig with existing zone name shows error.""" + from mudlib.commands.build import cmd_dig + + await cmd_dig(player, "hub 5 5") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[-1][0][0] + assert "already exists" in written.lower() + + +@pytest.mark.asyncio +async def test_dig_bad_args(player, mock_writer): + """@dig with wrong number of args shows usage.""" + from mudlib.commands.build import cmd_dig + + await cmd_dig(player, "cave") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[0][0][0] + assert "usage" in written.lower() or "dig" in written.lower() + + +# --- @save --- + + +@pytest.mark.asyncio +async def test_save_zone(player, zone, tmp_path, mock_writer): + """@save writes zone to TOML file.""" + from mudlib.commands.build import cmd_save, set_content_dir + + set_content_dir(tmp_path) + zones_dir = tmp_path / "zones" + zones_dir.mkdir() + + await cmd_save(player, "") + + # Check file was created + saved_file = zones_dir / "hub.toml" + assert saved_file.exists() + content = saved_file.read_text() + assert 'name = "hub"' in content + + # Check confirmation message + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[-1][0][0] + assert "saved" in written.lower() + + +# --- @place --- + + +@pytest.mark.asyncio +async def test_place_thing(player, zone, mock_writer): + """@place puts a thing at player's position.""" + from mudlib.commands.build import cmd_place + from mudlib.things import ThingTemplate, thing_templates + + thing_templates["sign"] = ThingTemplate( + name="sign", + description="a wooden sign", + ) + + await cmd_place(player, "sign") + + # Check thing is in zone at player's position + from mudlib.thing import Thing + + things = [ + obj + for obj in zone.contents_at(5, 5) + if isinstance(obj, Thing) and obj.name == "sign" + ] + assert len(things) == 1 + assert things[0].location is zone + + # Clean up + del thing_templates["sign"] + + +@pytest.mark.asyncio +async def test_place_unknown_thing(player, mock_writer): + """@place with unknown thing name shows error.""" + from mudlib.commands.build import cmd_place + + await cmd_place(player, "unicorn") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[0][0][0] + assert "not found" in written.lower() or "unknown" in written.lower() + + +@pytest.mark.asyncio +async def test_place_no_args(player, mock_writer): + """@place without arguments shows usage.""" + from mudlib.commands.build import cmd_place + + await cmd_place(player, "") + + mock_writer.write.assert_called() + written = mock_writer.write.call_args_list[0][0][0] + assert "usage" in written.lower() or "place" in written.lower()