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
This commit is contained in:
parent
dd5286097b
commit
71f4ae4ec4
2 changed files with 369 additions and 0 deletions
116
src/mudlib/commands/build.py
Normal file
116
src/mudlib/commands/build.py
Normal file
|
|
@ -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 <zone_name>\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 <name> <width> <height>\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 <thing_name>\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"))
|
||||||
253
tests/test_build_commands.py
Normal file
253
tests/test_build_commands.py
Normal file
|
|
@ -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()
|
||||||
Loading…
Reference in a new issue