From 5d140116846bb689375837ccd3cc772057f47c01 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Sat, 14 Feb 2026 17:41:27 -0500 Subject: [PATCH] Add terrain editing command for home zones --- src/mudlib/commands/terrain.py | 72 ++++++++++++ tests/test_terrain_edit.py | 209 +++++++++++++++++++++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 src/mudlib/commands/terrain.py create mode 100644 tests/test_terrain_edit.py diff --git a/src/mudlib/commands/terrain.py b/src/mudlib/commands/terrain.py new file mode 100644 index 0000000..dcbba38 --- /dev/null +++ b/src/mudlib/commands/terrain.py @@ -0,0 +1,72 @@ +"""Terrain editing command for home zones.""" + +from mudlib.commands import CommandDefinition, register +from mudlib.housing import save_home_zone +from mudlib.player import Player +from mudlib.zone import Zone + + +async def cmd_terrain(player: Player, args: str) -> None: + """Paint terrain tiles in your home zone. + + Usage: + terrain — paint terrain at your current position + + Common tiles: + . grass + ~ water + ^ mountain + T tree + , dirt + " tall grass + """ + zone = player.location + + # Must be in a Zone + if not isinstance(zone, Zone): + await player.send("You need to be in a zone to edit terrain.\r\n") + return + + # Must be in home zone + if zone.name != player.home_zone: + await player.send("You can only edit terrain in your home zone.\r\n") + return + + # Check for args + if not args.strip(): + await player.send( + "Usage: terrain \r\n\r\n" + "Common tiles: . (grass), ~ (water), ^ (mountain), " + 'T (tree), , (dirt), " (tall grass)\r\n' + ) + return + + tile = args.strip() + + # Must be single character + if len(tile) != 1: + await player.send("Tile must be a single character.\r\n") + return + + # Cannot edit border tiles + x, y = player.x, player.y + if x == 0 or x == zone.width - 1 or y == 0 or y == zone.height - 1: + await player.send("You cannot edit the border walls.\r\n") + return + + # Paint the tile + zone.terrain[y][x] = tile + + # Save the zone + save_home_zone(player.name, zone) + + await player.send(f"You paint the ground beneath you as '{tile}'.\r\n") + + +register( + CommandDefinition( + "terrain", + cmd_terrain, + help="Paint terrain tiles in your home zone.", + ) +) diff --git a/tests/test_terrain_edit.py b/tests/test_terrain_edit.py new file mode 100644 index 0000000..1084808 --- /dev/null +++ b/tests/test_terrain_edit.py @@ -0,0 +1,209 @@ +"""Tests for terrain editing command.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.commands.terrain import cmd_terrain +from mudlib.housing import init_housing +from mudlib.player import Player +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_home_zone(player_name="alice"): + """Create a home zone matching housing.py format.""" + name = f"home:{player_name}" + terrain = [] + for y in range(9): + row = [] + for x in range(9): + if x == 0 or x == 8 or y == 0 or y == 8: + row.append("#") + else: + row.append(".") + terrain.append(row) + zone = Zone( + name=name, + description=f"{player_name}'s home", + width=9, + height=9, + terrain=terrain, + toroidal=False, + impassable={"#", "^", "~"}, + spawn_x=4, + spawn_y=4, + safe=True, + ) + register_zone(name, zone) + return zone + + +def _make_player(name="alice", zone=None, x=4, y=4): + """Create a test player with mock writer.""" + mock_writer = MagicMock() + mock_writer.write = MagicMock() + mock_writer.drain = AsyncMock() + p = Player(name=name, location=zone, x=x, y=y, writer=mock_writer) + p.home_zone = f"home:{name}" + return p + + +@pytest.mark.asyncio +async def test_terrain_paint_tile(tmp_path): + """terrain changes terrain at current position.""" + init_housing(tmp_path) + zone = _make_home_zone("alice") + player = _make_player("alice", zone=zone, x=4, y=4) + + # Verify starting terrain + assert zone.terrain[4][4] == "." + + # Paint a new tile + await cmd_terrain(player, "~") + + # Verify terrain changed + assert zone.terrain[4][4] == "~" + + # Verify success message + player.writer.write.assert_called() + messages = "".join(call[0][0] for call in player.writer.write.call_args_list) + assert "paint" in messages.lower() + + +@pytest.mark.asyncio +async def test_terrain_not_in_home_zone(): + """terrain command fails when not in home zone.""" + # Create home zone but place player in different zone + _make_home_zone("alice") + overworld = Zone( + name="overworld", + description="The overworld", + width=50, + height=50, + terrain=[["."] * 50 for _ in range(50)], + toroidal=True, + impassable=set(), + spawn_x=25, + spawn_y=25, + safe=False, + ) + register_zone("overworld", overworld) + player = _make_player("alice", zone=overworld, x=25, y=25) + + await cmd_terrain(player, "~") + + # Verify error message + player.writer.write.assert_called() + messages = "".join(call[0][0] for call in player.writer.write.call_args_list) + assert "home" in messages.lower() or "can't" in messages.lower() + + +@pytest.mark.asyncio +async def test_terrain_cannot_edit_border(): + """terrain command prevents editing border tiles.""" + zone = _make_home_zone("alice") + + # Test all border positions + border_positions = [ + (0, 0), # top-left corner + (4, 0), # top edge + (8, 0), # top-right corner + (0, 4), # left edge + (8, 4), # right edge + (0, 8), # bottom-left corner + (4, 8), # bottom edge + (8, 8), # bottom-right corner + ] + + for x, y in border_positions: + player = _make_player("alice", zone=zone, x=x, y=y) + + await cmd_terrain(player, ".") + + # Verify error message + player.writer.write.assert_called() + messages = "".join(call[0][0] for call in player.writer.write.call_args_list) + assert "border" in messages.lower() or "wall" in messages.lower() + + # Verify terrain unchanged + assert zone.terrain[y][x] == "#" + + +@pytest.mark.asyncio +async def test_terrain_no_args(): + """terrain with no args shows usage.""" + zone = _make_home_zone("alice") + player = _make_player("alice", zone=zone, x=4, y=4) + + await cmd_terrain(player, "") + + # Verify usage message + player.writer.write.assert_called() + messages = "".join(call[0][0] for call in player.writer.write.call_args_list) + assert "usage" in messages.lower() or "terrain <" in messages.lower() + + +@pytest.mark.asyncio +async def test_terrain_only_single_char(): + """terrain rejects multi-character arguments.""" + zone = _make_home_zone("alice") + player = _make_player("alice", zone=zone, x=4, y=4) + + await cmd_terrain(player, "~~") + + # Verify error message + player.writer.write.assert_called() + messages = "".join(call[0][0] for call in player.writer.write.call_args_list) + assert "single" in messages.lower() or "one character" in messages.lower() + + # Verify terrain unchanged + assert zone.terrain[4][4] == "." + + +@pytest.mark.asyncio +async def test_terrain_saves_zone(tmp_path): + """terrain command saves zone to TOML after edit.""" + import tomllib + + init_housing(tmp_path) + zone = _make_home_zone("alice") + player = _make_player("alice", zone=zone, x=4, y=4) + + # Paint a tile + await cmd_terrain(player, "~") + + # Verify TOML file was updated + zone_file = tmp_path / "alice.toml" + assert zone_file.exists() + + with open(zone_file, "rb") as f: + data = tomllib.load(f) + + # Check that row 4 contains the water tile at position 4 + rows = data["terrain"]["rows"] + assert rows[4][4] == "~" + + +@pytest.mark.asyncio +async def test_terrain_various_tiles(tmp_path): + """terrain accepts various tile characters.""" + init_housing(tmp_path) + zone = _make_home_zone("alice") + + test_tiles = [".", "~", "^", "T", ",", '"', "*", "+", "="] + + for tile in test_tiles: + player = _make_player("alice", zone=zone, x=4, y=4) + await cmd_terrain(player, tile) + assert zone.terrain[4][4] == tile