diff --git a/src/mudlib/export.py b/src/mudlib/export.py index 5a3348a..fbd3ee3 100644 --- a/src/mudlib/export.py +++ b/src/mudlib/export.py @@ -90,6 +90,26 @@ def export_zone(zone: Zone) -> str: ) lines.append("") + # Boundaries (if any) + if zone.boundaries: + for boundary in zone.boundaries: + lines.append("[[boundaries]]") + lines.append(f'name = "{boundary.name}"') + lines.append(f"x = [{boundary.x_min}, {boundary.x_max}]") + lines.append(f"y = [{boundary.y_min}, {boundary.y_max}]") + if boundary.on_enter_message is not None: + escaped = boundary.on_enter_message.replace('"', '\\"') + lines.append(f'on_enter_message = "{escaped}"') + if boundary.on_exit_message is not None: + escaped = boundary.on_exit_message.replace('"', '\\"') + lines.append(f'on_exit_message = "{escaped}"') + if boundary.on_exit_check is not None: + lines.append(f'on_exit_check = "{boundary.on_exit_check}"') + if boundary.on_exit_fail is not None: + escaped = boundary.on_exit_fail.replace('"', '\\"') + lines.append(f'on_exit_fail = "{escaped}"') + lines.append("") + return "\n".join(lines) diff --git a/src/mudlib/zone.py b/src/mudlib/zone.py index ed3b1be..3258d75 100644 --- a/src/mudlib/zone.py +++ b/src/mudlib/zone.py @@ -8,6 +8,28 @@ from dataclasses import dataclass, field from mudlib.object import Object +@dataclass(eq=False) +class BoundaryRegion: + """A rectangular region within a zone that can trigger effects. + + Used for anti-theft detection, level gates, messages, etc. + """ + + name: str + x_min: int + x_max: int + y_min: int + y_max: int + on_enter_message: str | None = None + on_exit_message: str | None = None + on_exit_check: str | None = None + on_exit_fail: str | None = None + + def contains(self, x: int, y: int) -> bool: + """Check if a position is inside this boundary region.""" + return self.x_min <= x <= self.x_max and self.y_min <= y <= self.y_max + + @dataclass(eq=False) class SpawnRule: """Configuration for spawning mobs in a zone. @@ -43,6 +65,7 @@ class Zone(Object): ambient_interval: int = 120 spawn_rules: list[SpawnRule] = field(default_factory=list) safe: bool = False + boundaries: list[BoundaryRegion] = field(default_factory=list) def can_accept(self, obj: Object) -> bool: """Zones accept everything.""" diff --git a/src/mudlib/zones.py b/src/mudlib/zones.py index f711cd9..81676f9 100644 --- a/src/mudlib/zones.py +++ b/src/mudlib/zones.py @@ -7,7 +7,7 @@ import tomllib from pathlib import Path from mudlib.portal import Portal -from mudlib.zone import SpawnRule, Zone +from mudlib.zone import BoundaryRegion, SpawnRule, Zone log = logging.getLogger(__name__) @@ -86,6 +86,23 @@ def load_zone(path: Path) -> Zone: for spawn in spawns_data ] + # Parse boundaries + boundaries_data = data.get("boundaries", []) + boundaries = [ + BoundaryRegion( + name=boundary["name"], + x_min=boundary["x"][0], + x_max=boundary["x"][1], + y_min=boundary["y"][0], + y_max=boundary["y"][1], + on_enter_message=boundary.get("on_enter_message"), + on_exit_message=boundary.get("on_exit_message"), + on_exit_check=boundary.get("on_exit_check"), + on_exit_fail=boundary.get("on_exit_fail"), + ) + for boundary in boundaries_data + ] + zone = Zone( name=name, description=description, @@ -100,6 +117,7 @@ def load_zone(path: Path) -> Zone: ambient_interval=ambient_interval, spawn_rules=spawn_rules, safe=safe, + boundaries=boundaries, ) # Load portals diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py new file mode 100644 index 0000000..9b4c55a --- /dev/null +++ b/tests/test_boundaries.py @@ -0,0 +1,418 @@ +"""Tests for zone boundary regions.""" + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from mudlib.export import export_zone +from mudlib.player import Player +from mudlib.thing import Thing +from mudlib.zone import BoundaryRegion, Zone +from mudlib.zones import load_zone + + +def create_mock_writer(): + """Create a mock writer for testing.""" + writer = MagicMock() + writer.write = MagicMock() + writer.drain = AsyncMock() + return writer + + +def test_boundary_contains(): + """Test BoundaryRegion.contains() method.""" + boundary = BoundaryRegion( + name="test_area", + x_min=10, + x_max=15, + y_min=8, + y_max=12, + ) + + # Inside the boundary + assert boundary.contains(10, 8) + assert boundary.contains(15, 12) + assert boundary.contains(12, 10) + + # Outside the boundary + assert not boundary.contains(9, 10) + assert not boundary.contains(16, 10) + assert not boundary.contains(12, 7) + assert not boundary.contains(12, 13) + + +def test_boundary_parsing_from_toml(tmp_path): + """Test loading boundaries from TOML.""" + toml_content = """ +name = "test_zone" +width = 20 +height = 20 + +[terrain] +rows = [ + ".....................", + ".....................", + ".....................", +] + +[terrain.impassable] +tiles = ["^"] + +[[boundaries]] +name = "vault" +x = [10, 15] +y = [8, 12] +on_enter_message = "You enter the vault." +on_exit_message = "You leave the vault." +on_exit_check = "carrying:treasure" +on_exit_fail = "Guards block your path!" + +[[boundaries]] +name = "simple" +x = [0, 5] +y = [0, 5] +""" + + zone_file = tmp_path / "test.toml" + zone_file.write_text(toml_content) + + zone = load_zone(zone_file) + + assert len(zone.boundaries) == 2 + + vault = zone.boundaries[0] + assert vault.name == "vault" + assert vault.x_min == 10 + assert vault.x_max == 15 + assert vault.y_min == 8 + assert vault.y_max == 12 + assert vault.on_enter_message == "You enter the vault." + assert vault.on_exit_message == "You leave the vault." + assert vault.on_exit_check == "carrying:treasure" + assert vault.on_exit_fail == "Guards block your path!" + + simple = zone.boundaries[1] + assert simple.name == "simple" + assert simple.x_min == 0 + assert simple.x_max == 5 + assert simple.y_min == 0 + assert simple.y_max == 5 + assert simple.on_enter_message is None + assert simple.on_exit_message is None + assert simple.on_exit_check is None + assert simple.on_exit_fail is None + + +def test_boundary_export_to_toml(): + """Test exporting boundaries to TOML.""" + zone = Zone( + name="test_zone", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=8, + y_max=12, + on_enter_message="You enter the vault.", + on_exit_message="You leave the vault.", + on_exit_check="carrying:treasure", + on_exit_fail="Guards block your path!", + ), + ], + ) + + toml_str = export_zone(zone) + + assert "[[boundaries]]" in toml_str + assert 'name = "vault"' in toml_str + assert "x = [10, 15]" in toml_str + assert "y = [8, 12]" in toml_str + assert 'on_enter_message = "You enter the vault."' in toml_str + assert 'on_exit_message = "You leave the vault."' in toml_str + assert 'on_exit_check = "carrying:treasure"' in toml_str + assert 'on_exit_fail = "Guards block your path!"' in toml_str + + +def test_boundary_roundtrip(tmp_path): + """Test TOML -> load -> export -> load round-trip.""" + original = Zone( + name="test_zone", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="area1", + x_min=5, + x_max=10, + y_min=5, + y_max=10, + on_enter_message="Enter area1", + ), + ], + ) + + # Export to file + zone_file = tmp_path / "test.toml" + toml_str = export_zone(original) + zone_file.write_text(toml_str) + + # Load it back + loaded = load_zone(zone_file) + + # Verify boundaries survived the round-trip + assert len(loaded.boundaries) == 1 + assert loaded.boundaries[0].name == "area1" + assert loaded.boundaries[0].x_min == 5 + assert loaded.boundaries[0].x_max == 10 + assert loaded.boundaries[0].on_enter_message == "Enter area1" + + +@pytest.mark.asyncio +async def test_boundary_enter_message(): + """Test on_enter_message sent when entering boundary.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=10, + y_max=15, + on_enter_message="You step into the vault.", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=9, y=10, writer=writer) + + # Move east into the boundary + await move_player(player, 1, 0, "east") + + # Check that the message was written + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "You step into the vault." in written_text + + +@pytest.mark.asyncio +async def test_boundary_exit_message(): + """Test on_exit_message sent when leaving boundary.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=10, + y_max=15, + on_exit_message="You leave the vault.", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer) + + # Move east out of the boundary + await move_player(player, 1, 0, "east") + + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "You leave the vault." in written_text + + +@pytest.mark.asyncio +async def test_carrying_check_blocks_exit(): + """Test carrying check blocks movement when holding matching item.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=10, + y_max=15, + on_exit_check="carrying:treasure", + on_exit_fail="Guards block your path!", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer) + + # Give player an item named "treasure" + Thing(name="treasure", location=player) + + # Try to move east out of boundary + await move_player(player, 1, 0, "east") + + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "Guards block your path!" in written_text + # Player should still be at original position + assert player.x == 15 + assert player.y == 10 + + +@pytest.mark.asyncio +async def test_carrying_check_allows_exit_without_item(): + """Test carrying check allows movement without matching item.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=10, + y_max=15, + on_exit_check="carrying:treasure", + on_exit_fail="Guards block your path!", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer) + + # Move east out of boundary (no treasure in inventory) + await move_player(player, 1, 0, "east") + + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "Guards block your path!" not in written_text + # Player should have moved + assert player.x == 16 + assert player.y == 10 + + +@pytest.mark.asyncio +async def test_carrying_check_matches_by_tag(): + """Test carrying check matches items by tag.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[ + BoundaryRegion( + name="vault", + x_min=10, + x_max=15, + y_min=10, + y_max=15, + on_exit_check="carrying:valuable", + on_exit_fail="Guards block your path!", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=15, y=10, writer=writer) + + # Give player an item with "valuable" tag + Thing(name="gem", location=player, tags=["valuable", "shiny"]) + + # Try to move east out of boundary + await move_player(player, 1, 0, "east") + + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "Guards block your path!" in written_text + assert player.x == 15 + + +@pytest.mark.asyncio +async def test_no_boundaries_no_effect(): + """Test that zones with no boundaries work normally.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=20, + height=20, + terrain=[["." for _ in range(20)] for _ in range(20)], + boundaries=[], # No boundaries + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=10, y=10, writer=writer) + + # Movement should work normally + await move_player(player, 1, 0, "east") + assert player.x == 11 + + +@pytest.mark.asyncio +async def test_multiple_boundaries_in_zone(): + """Test multiple boundaries in the same zone.""" + from mudlib.commands.movement import move_player + + zone = Zone( + name="test", + width=30, + height=30, + terrain=[["." for _ in range(30)] for _ in range(30)], + boundaries=[ + BoundaryRegion( + name="area1", + x_min=5, + x_max=10, + y_min=5, + y_max=10, + on_enter_message="Enter area1", + ), + BoundaryRegion( + name="area2", + x_min=15, + x_max=20, + y_min=15, + y_max=20, + on_enter_message="Enter area2", + ), + ], + ) + + writer = create_mock_writer() + player = Player(name="TestPlayer", location=zone, x=4, y=5, writer=writer) + + # Move into area1 + await move_player(player, 1, 0, "east") + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "Enter area1" in written_text + + # Move to neutral zone + writer.write.reset_mock() + player.x, player.y = 14, 15 + + # Move into area2 + await move_player(player, 1, 0, "east") + written_text = "".join(call[0][0] for call in writer.write.call_args_list) + assert "Enter area2" in written_text