mud/src/mudlib/zone.py
Jared Miller b5c5542792
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.
2026-02-14 12:39:48 -05:00

163 lines
4.9 KiB
Python

"""Zone — a spatial area with a terrain grid."""
from __future__ import annotations
import random
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.
Defines what mob should spawn, how many can exist at once,
and how long to wait before respawning after one dies.
"""
mob: str
max_count: int = 1
respawn_seconds: int = 300
home_region: dict | None = None
@dataclass(eq=False)
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.
"""
description: str = ""
width: int = 0
height: int = 0
toroidal: bool = True
terrain: list[list[str]] = field(default_factory=list)
impassable: set[str] = field(default_factory=lambda: {"^", "~"})
spawn_x: int = 0
spawn_y: int = 0
ambient_messages: list[str] = field(default_factory=list)
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."""
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]
def contents_near(self, x: int, y: int, range_: int) -> list[Object]:
"""Return all contents within range_ tiles of (x, y).
Uses wrapping-aware Manhattan distance for toroidal zones.
Includes objects at exactly (x, y).
Args:
x: X coordinate of center point
y: Y coordinate of center point
range_: Maximum Manhattan distance (inclusive)
Returns:
List of all objects within range
"""
nearby = []
for obj in self._contents:
# Skip objects without coordinates
if obj.x is None or obj.y is None:
continue
dx_dist = abs(obj.x - x)
dy_dist = abs(obj.y - y)
if self.toroidal:
# Use wrapping-aware distance
dx_dist = min(dx_dist, self.width - dx_dist)
dy_dist = min(dy_dist, self.height - dy_dist)
manhattan_dist = dx_dist + dy_dist
if manhattan_dist <= range_:
nearby.append(obj)
return nearby
def get_ambient_message(zone: Zone) -> str | None:
"""Return a random ambient message from the zone's list.
Args:
zone: The zone to get an ambient message from
Returns:
A random message from the zone's ambient_messages list,
or None if the list is empty
"""
if not zone.ambient_messages:
return None
return random.choice(zone.ambient_messages)