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().
This commit is contained in:
parent
51dc583818
commit
b4fca95830
2 changed files with 271 additions and 0 deletions
69
src/mudlib/zone.py
Normal file
69
src/mudlib/zone.py
Normal file
|
|
@ -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]
|
||||
202
tests/test_zone.py
Normal file
202
tests/test_zone.py
Normal file
|
|
@ -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
|
||||
Loading…
Reference in a new issue