From b4fca958304850dcdccd774832b3f29439d938b2 Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Wed, 11 Feb 2026 19:08:30 -0500 Subject: [PATCH] Add Zone class with terrain, spatial queries, and viewport Zone(Object) is a spatial area with a terrain grid. Supports toroidal wrapping and bounded clamping, passability checks, viewport extraction, and contents_at(x, y) spatial queries. Zones are top-level containers (location=None) that accept everything via can_accept(). --- src/mudlib/zone.py | 69 ++++++++++++++++ tests/test_zone.py | 202 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 src/mudlib/zone.py create mode 100644 tests/test_zone.py diff --git a/src/mudlib/zone.py b/src/mudlib/zone.py new file mode 100644 index 0000000..f43b94a --- /dev/null +++ b/src/mudlib/zone.py @@ -0,0 +1,69 @@ +"""Zone — a spatial area with a terrain grid.""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from mudlib.object import Object + + +@dataclass +class Zone(Object): + """A spatial area with a grid of terrain tiles. + + Zones are top-level containers (location=None). Everything in the world + lives inside a zone — players, mobs, items, fixtures. The overworld is + a zone. A tavern interior is a zone. A pocket dimension is a zone. + """ + + width: int = 0 + height: int = 0 + toroidal: bool = True + terrain: list[list[str]] = field(default_factory=list) + impassable: set[str] = field(default_factory=lambda: {"^", "~"}) + + def can_accept(self, obj: Object) -> bool: + """Zones accept everything.""" + return True + + def wrap(self, x: int, y: int) -> tuple[int, int]: + """Wrap or clamp coordinates depending on zone type.""" + if self.toroidal: + return x % self.width, y % self.height + else: + return ( + max(0, min(x, self.width - 1)), + max(0, min(y, self.height - 1)), + ) + + def get_tile(self, x: int, y: int) -> str: + """Return terrain character at position (wrapping/clamping).""" + wx, wy = self.wrap(x, y) + return self.terrain[wy][wx] + + def is_passable(self, x: int, y: int) -> bool: + """Check if position is passable (wrapping/clamping).""" + return self.get_tile(x, y) not in self.impassable + + def get_viewport( + self, cx: int, cy: int, width: int, height: int + ) -> list[list[str]]: + """Return 2D slice of terrain centered on (cx, cy).""" + viewport = [] + half_w = width // 2 + half_h = height // 2 + start_x = cx - half_w + start_y = cy - half_h + + for dy in range(height): + row = [] + for dx in range(width): + wx, wy = self.wrap(start_x + dx, start_y + dy) + row.append(self.terrain[wy][wx]) + viewport.append(row) + + return viewport + + def contents_at(self, x: int, y: int) -> list[Object]: + """Return all contents at the given coordinates.""" + return [obj for obj in self._contents if obj.x == x and obj.y == y] diff --git a/tests/test_zone.py b/tests/test_zone.py new file mode 100644 index 0000000..ceef375 --- /dev/null +++ b/tests/test_zone.py @@ -0,0 +1,202 @@ +"""Tests for the Zone class.""" + +from mudlib.object import Object +from mudlib.zone import Zone + +# --- construction --- + + +def test_zone_basic_creation(): + """Zone created with name, dimensions, and terrain.""" + terrain = [["." for _ in range(10)] for _ in range(5)] + zone = Zone(name="test", width=10, height=5, terrain=terrain) + assert zone.name == "test" + assert zone.width == 10 + assert zone.height == 5 + assert zone.toroidal is True # default + assert zone.location is None # zones are top-level + + +def test_zone_bounded(): + """Zone can be created with toroidal=False (bounded).""" + terrain = [["." for _ in range(5)] for _ in range(5)] + zone = Zone(name="room", width=5, height=5, terrain=terrain, toroidal=False) + assert zone.toroidal is False + + +def test_zone_default_impassable(): + """Zone has default impassable set of mountain and water.""" + terrain = [["."]] + zone = Zone(name="test", width=1, height=1, terrain=terrain) + assert "^" in zone.impassable + assert "~" in zone.impassable + + +# --- can_accept --- + + +def test_zone_can_accept_returns_true(): + """Zones accept everything.""" + terrain = [["."]] + zone = Zone(name="test", width=1, height=1, terrain=terrain) + obj = Object(name="rock") + assert zone.can_accept(obj) is True + + +# --- wrap --- + + +def test_wrap_toroidal(): + """Toroidal zone wraps coordinates around both axes.""" + terrain = [["." for _ in range(100)] for _ in range(100)] + zone = Zone(name="overworld", width=100, height=100, terrain=terrain) + assert zone.wrap(105, 205) == (5, 5) + assert zone.wrap(-1, -1) == (99, 99) + assert zone.wrap(50, 50) == (50, 50) + + +def test_wrap_bounded(): + """Bounded zone clamps coordinates to edges.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="room", width=10, height=10, terrain=terrain, toroidal=False) + assert zone.wrap(15, 15) == (9, 9) + assert zone.wrap(-5, -3) == (0, 0) + assert zone.wrap(5, 5) == (5, 5) + + +# --- get_tile --- + + +def test_get_tile(): + """get_tile returns the terrain character at a position.""" + terrain = [ + [".", "T", "^"], + ["~", ".", ":"], + ] + zone = Zone(name="test", width=3, height=2, terrain=terrain) + assert zone.get_tile(0, 0) == "." + assert zone.get_tile(1, 0) == "T" + assert zone.get_tile(2, 0) == "^" + assert zone.get_tile(0, 1) == "~" + assert zone.get_tile(2, 1) == ":" + + +def test_get_tile_wraps(): + """get_tile wraps coordinates on toroidal zone.""" + terrain = [["." for _ in range(5)] for _ in range(5)] + terrain[0][0] = "T" + zone = Zone(name="test", width=5, height=5, terrain=terrain) + assert zone.get_tile(5, 5) == "T" # wraps to (0, 0) + + +# --- is_passable --- + + +def test_is_passable_grass(): + """Grass tiles are passable.""" + terrain = [["."]] + zone = Zone(name="test", width=1, height=1, terrain=terrain) + assert zone.is_passable(0, 0) is True + + +def test_is_passable_mountain(): + """Mountain tiles are impassable.""" + terrain = [["^"]] + zone = Zone(name="test", width=1, height=1, terrain=terrain) + assert zone.is_passable(0, 0) is False + + +def test_is_passable_water(): + """Water tiles are impassable.""" + terrain = [["~"]] + zone = Zone(name="test", width=1, height=1, terrain=terrain) + assert zone.is_passable(0, 0) is False + + +def test_is_passable_custom_impassable(): + """Zone can have custom impassable set.""" + terrain = [[".", "X"]] + zone = Zone(name="test", width=2, height=1, terrain=terrain, impassable={"X"}) + assert zone.is_passable(0, 0) is True + assert zone.is_passable(1, 0) is False + + +# --- get_viewport --- + + +def test_get_viewport_centered(): + """Viewport returns terrain slice centered on given position.""" + terrain = [["." for _ in range(21)] for _ in range(11)] + terrain[5][10] = "@" # center + zone = Zone(name="test", width=21, height=11, terrain=terrain) + vp = zone.get_viewport(10, 5, 5, 3) + # 5 wide, 3 tall, centered on (10, 5) + # Should be columns 8-12, rows 4-6 + assert len(vp) == 3 + assert len(vp[0]) == 5 + assert vp[1][2] == "@" # center of viewport + + +def test_get_viewport_wraps_toroidal(): + """Viewport wraps around edges of toroidal zone.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + terrain[0][0] = "T" # top-left corner + zone = Zone(name="test", width=10, height=10, terrain=terrain) + # Viewport centered at (0, 0), size 5x5 + vp = zone.get_viewport(0, 0, 5, 5) + # Center of viewport is (2, 2), should be "T" + assert vp[2][2] == "T" + # Top-left of viewport wraps to bottom-right of zone + assert len(vp) == 5 + assert len(vp[0]) == 5 + + +def test_get_viewport_full_size(): + """Viewport with standard 21x11 size works.""" + terrain = [["." for _ in range(100)] for _ in range(100)] + zone = Zone(name="test", width=100, height=100, terrain=terrain) + vp = zone.get_viewport(50, 50, 21, 11) + assert len(vp) == 11 + assert len(vp[0]) == 21 + + +# --- contents_at --- + + +def test_contents_at_empty(): + """contents_at returns empty list when nothing at position.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + assert zone.contents_at(5, 5) == [] + + +def test_contents_at_finds_objects(): + """contents_at returns objects at the given coordinates.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + obj = Object(name="rock", location=zone, x=3, y=7) + result = zone.contents_at(3, 7) + assert obj in result + + +def test_contents_at_excludes_other_positions(): + """contents_at only returns objects at exact coordinates.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + Object(name="rock", location=zone, x=3, y=7) + Object(name="tree", location=zone, x=5, y=5) + result = zone.contents_at(3, 7) + assert len(result) == 1 + assert result[0].name == "rock" + + +def test_contents_at_multiple(): + """contents_at returns all objects at the same position.""" + terrain = [["." for _ in range(10)] for _ in range(10)] + zone = Zone(name="test", width=10, height=10, terrain=terrain) + rock = Object(name="rock", location=zone, x=5, y=5) + gem = Object(name="gem", location=zone, x=5, y=5) + result = zone.contents_at(5, 5) + assert rock in result + assert gem in result + assert len(result) == 2