Add boundary region data model with TOML parsing and export
Boundaries are rectangular regions within zones that can trigger effects when players enter or exit. Added BoundaryRegion dataclass with contains() method, TOML parsing in load_zone(), and export support. Tests verify parsing, export, and round-trip behavior.
This commit is contained in:
parent
da76b6004e
commit
b5c5542792
4 changed files with 480 additions and 1 deletions
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
418
tests/test_boundaries.py
Normal file
418
tests/test_boundaries.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue