Skip to content

MAID World System Enhancements Design Specification

Document Version: 1.0
Date: January 30, 2026
Status: Draft
Authors: MAID Development Team


Executive Summary

This document specifies enhancements to MAID's world systems to achieve feature parity with Evennia's spatial and environmental features. The three major enhancement areas are:

  1. XYZ Coordinate Grid System - Optional coordinate-based room indexing with pathfinding
  2. Wilderness/Procedural Generation - Dynamic room generation for large outdoor areas
  3. Extended Room Features - Enhanced room descriptions, seasonal variations, and detail systems

These enhancements address the gaps identified in the MAID vs Evennia comparison where Evennia leads with xyzgrid contrib, wilderness contrib, and extended room features.


Table of Contents

  1. Feature 1: XYZ Coordinate Grid System
  2. Feature 2: Wilderness/Procedural Generation
  3. Feature 3: Extended Room Features
  4. Appendix A: Performance Considerations
  5. Appendix B: Testing Requirements

Feature 1: XYZ Coordinate Grid System

1.1 Feature Overview

What it does:
Provides an optional coordinate-based spatial system where rooms are indexed by (x, y, z) coordinates. Enables automatic exit creation between adjacent rooms, A* pathfinding, distance calculations, and coordinate-based queries.

Why it's needed: - Current state: Rooms have optional coordinates field but no grid indexing or pathfinding - Cannot automatically create exits between adjacent coordinate rooms - No pathfinding for NPC navigation or player guidance - Evennia's xyzgrid contrib provides full coordinate-based world building

Current Opportunity:
MAID already has Coordinates class in models/world/map.py and RoomManager._by_coordinates index. This feature extends these foundations into a full grid system.

1.2 User Stories

US-1.1: Automatic Exit Creation

As a world builder, I want exits automatically created between adjacent coordinate rooms so that I don't have to manually link every room.

US-1.2: A* Pathfinding

As a game designer, I want NPCs to find paths between rooms so that they can patrol, chase, and navigate intelligently.

US-1.3: Distance Queries

As a game designer, I want to query rooms within a radius of a coordinate so that I can implement area effects and proximity alerts.

US-1.4: Coordinate-Based Building

As a world builder, I want to create rooms by specifying coordinates so that I can build grid-based dungeons and cities.

US-1.5: Multi-Level Support

As a world builder, I want the Z coordinate to represent different levels (floors, underground) so that I can build 3D structures.

US-1.6: Selective Grid Usage

As a world builder, I want some rooms to use coordinates and others to not so that I can mix grid-based and freeform areas.

1.3 Technical Requirements

1.3.1 Core Requirements

ID Requirement Priority
XYZ-001 System SHALL index rooms by (x, y, z) coordinates P0
XYZ-002 System SHALL support automatic exit creation for adjacent rooms P0
XYZ-003 System SHALL provide A* pathfinding between coordinate rooms P0
XYZ-004 System SHALL support distance queries (rooms within radius) P1
XYZ-005 System SHALL support rectangular region queries P1
XYZ-006 System SHALL coexist with non-coordinate rooms P0
XYZ-007 System SHALL support blocked cells (impassable coordinates) P1
XYZ-008 System SHALL support diagonal movement (8-directional) P1
XYZ-009 System SHALL support vertical movement (up/down) P0
XYZ-010 System SHALL provide efficient spatial queries (O(1) for point lookup) P0
XYZ-011 System SHALL emit events when rooms are added/removed from grid P1
XYZ-012 System SHALL support multiple disconnected grids P2

1.3.2 Pathfinding Requirements

ID Requirement Priority
PATH-001 System SHALL implement A* algorithm for shortest path P0
PATH-002 System SHALL support movement cost weights per room P1
PATH-003 System SHALL respect locked doors in pathfinding P0
PATH-004 System SHALL support maximum path length limits P0
PATH-005 System SHALL cache frequently used paths P2
PATH-006 System SHALL support NPC-specific pathfinding constraints P1
PATH-007 System SHALL provide path as list of directions P0

1.4 API/Interface Design

1.4.1 Coordinate Grid Manager

# packages/maid-engine/src/maid_engine/core/grid.py

from __future__ import annotations

import heapq
import math
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Callable, Iterator
from uuid import UUID

if TYPE_CHECKING:
    from maid_engine.core.world import World


@dataclass(frozen=True)
class GridCoord:
    """An immutable 3D coordinate."""
    x: int
    y: int
    z: int

    def __add__(self, other: "GridCoord") -> "GridCoord":
        return GridCoord(self.x + other.x, self.y + other.y, self.z + other.z)

    def __sub__(self, other: "GridCoord") -> "GridCoord":
        return GridCoord(self.x - other.x, self.y - other.y, self.z - other.z)

    def distance_to(self, other: "GridCoord") -> float:
        """Euclidean distance to another coordinate."""
        dx = self.x - other.x
        dy = self.y - other.y
        dz = self.z - other.z
        return math.sqrt(dx*dx + dy*dy + dz*dz)

    def manhattan_distance(self, other: "GridCoord") -> int:
        """Manhattan distance (grid steps) to another coordinate."""
        return abs(self.x - other.x) + abs(self.y - other.y) + abs(self.z - other.z)

    def chebyshev_distance(self, other: "GridCoord") -> int:
        """Chebyshev distance (king's move) to another coordinate."""
        return max(
            abs(self.x - other.x),
            abs(self.y - other.y),
            abs(self.z - other.z)
        )

    def neighbors(self, include_diagonals: bool = True, include_vertical: bool = True) -> list["GridCoord"]:
        """Get adjacent coordinates."""
        coords = []

        # Cardinal directions
        for dx, dy in [(0, 1), (0, -1), (1, 0), (-1, 0)]:
            coords.append(GridCoord(self.x + dx, self.y + dy, self.z))

        # Diagonal directions
        if include_diagonals:
            for dx, dy in [(1, 1), (1, -1), (-1, 1), (-1, -1)]:
                coords.append(GridCoord(self.x + dx, self.y + dy, self.z))

        # Vertical
        if include_vertical:
            coords.append(GridCoord(self.x, self.y, self.z + 1))
            coords.append(GridCoord(self.x, self.y, self.z - 1))

        return coords

    def direction_to(self, other: "GridCoord") -> str | None:
        """Get compass direction to adjacent coordinate."""
        dx = other.x - self.x
        dy = other.y - self.y
        dz = other.z - self.z

        if dz != 0:
            return "up" if dz > 0 else "down"

        DIRECTIONS = {
            (0, 1): "north",
            (0, -1): "south",
            (1, 0): "east",
            (-1, 0): "west",
            (1, 1): "northeast",
            (1, -1): "southeast",
            (-1, 1): "northwest",
            (-1, -1): "southwest",
        }

        return DIRECTIONS.get((dx, dy))

    @staticmethod
    def from_direction(direction: str) -> "GridCoord":
        """Get coordinate offset for a direction."""
        OFFSETS = {
            "north": GridCoord(0, 1, 0),
            "south": GridCoord(0, -1, 0),
            "east": GridCoord(1, 0, 0),
            "west": GridCoord(-1, 0, 0),
            "northeast": GridCoord(1, 1, 0),
            "northwest": GridCoord(-1, 1, 0),
            "southeast": GridCoord(1, -1, 0),
            "southwest": GridCoord(-1, -1, 0),
            "up": GridCoord(0, 0, 1),
            "down": GridCoord(0, 0, -1),
        }
        return OFFSETS.get(direction, GridCoord(0, 0, 0))


@dataclass
class GridRoom:
    """A room in the coordinate grid."""
    room_id: UUID
    coord: GridCoord
    movement_cost: float = 1.0
    blocked: bool = False
    terrain_type: str = "default"
    metadata: dict[str, Any] = field(default_factory=dict)


@dataclass
class PathResult:
    """Result of a pathfinding operation."""
    found: bool
    path: list[GridCoord]
    directions: list[str]
    room_ids: list[UUID]
    total_cost: float
    nodes_explored: int

    def __len__(self) -> int:
        return len(self.path)

    def __iter__(self) -> Iterator[GridCoord]:
        return iter(self.path)


class GridManager:
    """Manages coordinate-based room indexing and pathfinding."""

    def __init__(self, world: World):
        self.world = world

        # Primary index: coord -> GridRoom
        self._grid: dict[GridCoord, GridRoom] = {}

        # Reverse index: room_id -> GridRoom
        self._room_to_grid: dict[UUID, GridRoom] = {}

        # Blocked coordinates (no room, but not passable)
        self._blocked: set[GridCoord] = set()

        # Configuration
        self.auto_create_exits: bool = True
        self.allow_diagonals: bool = True
        self.allow_vertical: bool = True

    # =========================================================================
    # Room Registration
    # =========================================================================

    def register_room(
        self,
        room_id: UUID,
        x: int,
        y: int,
        z: int = 0,
        movement_cost: float = 1.0,
        terrain_type: str = "default",
        auto_connect: bool = True,
    ) -> GridRoom:
        """Register a room at a coordinate.

        Args:
            room_id: The room entity UUID
            x, y, z: Coordinates
            movement_cost: Cost multiplier for pathfinding
            terrain_type: Type of terrain (for display/effects)
            auto_connect: Whether to automatically create exits to neighbors

        Returns:
            The created GridRoom

        Raises:
            ValueError: If coordinate already occupied
        """
        coord = GridCoord(x, y, z)

        if coord in self._grid:
            raise ValueError(f"Coordinate {coord} already occupied")

        if coord in self._blocked:
            raise ValueError(f"Coordinate {coord} is blocked")

        grid_room = GridRoom(
            room_id=room_id,
            coord=coord,
            movement_cost=movement_cost,
            terrain_type=terrain_type,
        )

        self._grid[coord] = grid_room
        self._room_to_grid[room_id] = grid_room

        # Auto-create exits
        if auto_connect and self.auto_create_exits:
            self._connect_to_neighbors(grid_room)

        return grid_room

    def unregister_room(self, room_id: UUID) -> None:
        """Remove a room from the grid."""
        grid_room = self._room_to_grid.pop(room_id, None)
        if grid_room:
            del self._grid[grid_room.coord]

    def _connect_to_neighbors(self, grid_room: GridRoom) -> None:
        """Create exits to adjacent rooms."""
        neighbors = grid_room.coord.neighbors(
            include_diagonals=self.allow_diagonals,
            include_vertical=self.allow_vertical,
        )

        for neighbor_coord in neighbors:
            neighbor = self._grid.get(neighbor_coord)
            if neighbor:
                direction = grid_room.coord.direction_to(neighbor_coord)
                if direction:
                    # Create bidirectional exits
                    self._create_exit(grid_room.room_id, neighbor.room_id, direction)

    def _create_exit(self, from_room: UUID, to_room: UUID, direction: str) -> None:
        """Create an exit between rooms (via world's exit system)."""
        # This would call into the room/exit system
        # Implementation depends on how Room model handles exits
        pass

    # =========================================================================
    # Queries
    # =========================================================================

    def get_room_at(self, x: int, y: int, z: int = 0) -> UUID | None:
        """Get room at coordinate."""
        coord = GridCoord(x, y, z)
        grid_room = self._grid.get(coord)
        return grid_room.room_id if grid_room else None

    def get_coord(self, room_id: UUID) -> GridCoord | None:
        """Get coordinate for a room."""
        grid_room = self._room_to_grid.get(room_id)
        return grid_room.coord if grid_room else None

    def rooms_in_radius(
        self,
        center_x: int,
        center_y: int,
        center_z: int,
        radius: float,
    ) -> list[GridRoom]:
        """Get all rooms within Euclidean radius of a point."""
        center = GridCoord(center_x, center_y, center_z)
        results = []

        # Calculate bounding box for efficiency
        r_int = int(radius) + 1
        for x in range(center_x - r_int, center_x + r_int + 1):
            for y in range(center_y - r_int, center_y + r_int + 1):
                for z in range(center_z - r_int, center_z + r_int + 1):
                    coord = GridCoord(x, y, z)
                    if coord in self._grid and center.distance_to(coord) <= radius:
                        results.append(self._grid[coord])

        return results

    def rooms_in_rect(
        self,
        min_x: int,
        min_y: int,
        max_x: int,
        max_y: int,
        z: int | None = None,
    ) -> list[GridRoom]:
        """Get all rooms in a rectangular region."""
        results = []

        for x in range(min_x, max_x + 1):
            for y in range(min_y, max_y + 1):
                if z is not None:
                    coord = GridCoord(x, y, z)
                    if coord in self._grid:
                        results.append(self._grid[coord])
                else:
                    # All Z levels
                    for grid_room in self._grid.values():
                        if grid_room.coord.x == x and grid_room.coord.y == y:
                            results.append(grid_room)

        return results

    def nearest_room(
        self,
        x: int,
        y: int,
        z: int = 0,
        max_distance: float = float('inf'),
    ) -> GridRoom | None:
        """Find nearest room to a point."""
        target = GridCoord(x, y, z)
        nearest = None
        min_dist = max_distance

        for grid_room in self._grid.values():
            dist = target.distance_to(grid_room.coord)
            if dist < min_dist:
                min_dist = dist
                nearest = grid_room

        return nearest

    # =========================================================================
    # Pathfinding
    # =========================================================================

    def find_path(
        self,
        start_room: UUID,
        end_room: UUID,
        max_length: int = 1000,
        can_pass: Callable[[GridRoom], bool] | None = None,
    ) -> PathResult:
        """Find shortest path between two rooms using A*.

        Args:
            start_room: Starting room UUID
            end_room: Destination room UUID
            max_length: Maximum path length to search
            can_pass: Optional function to check if room is passable

        Returns:
            PathResult with path information
        """
        start = self._room_to_grid.get(start_room)
        end = self._room_to_grid.get(end_room)

        if not start or not end:
            return PathResult(
                found=False, path=[], directions=[], room_ids=[],
                total_cost=0, nodes_explored=0
            )

        return self._astar(start.coord, end.coord, max_length, can_pass)

    def _astar(
        self,
        start: GridCoord,
        goal: GridCoord,
        max_length: int,
        can_pass: Callable[[GridRoom], bool] | None,
    ) -> PathResult:
        """A* pathfinding implementation."""
        # Priority queue: (f_score, coord)
        open_set = [(0, start)]

        # Tracking
        came_from: dict[GridCoord, GridCoord] = {}
        g_score: dict[GridCoord, float] = {start: 0}
        nodes_explored = 0

        while open_set:
            _, current = heapq.heappop(open_set)
            nodes_explored += 1

            if current == goal:
                # Reconstruct path
                path = self._reconstruct_path(came_from, current)
                directions = self._path_to_directions(path)
                room_ids = [self._grid[c].room_id for c in path]
                total_cost = g_score[current]

                return PathResult(
                    found=True,
                    path=path,
                    directions=directions,
                    room_ids=room_ids,
                    total_cost=total_cost,
                    nodes_explored=nodes_explored,
                )

            # Check max length
            if len(came_from) > max_length:
                break

            # Explore neighbors
            neighbors = current.neighbors(
                include_diagonals=self.allow_diagonals,
                include_vertical=self.allow_vertical,
            )

            for neighbor in neighbors:
                if neighbor not in self._grid:
                    continue

                grid_room = self._grid[neighbor]

                if grid_room.blocked:
                    continue

                if can_pass and not can_pass(grid_room):
                    continue

                # Calculate costs
                move_cost = grid_room.movement_cost
                if neighbor.x != current.x and neighbor.y != current.y:
                    move_cost *= 1.414  # Diagonal cost

                tentative_g = g_score[current] + move_cost

                if tentative_g < g_score.get(neighbor, float('inf')):
                    came_from[neighbor] = current
                    g_score[neighbor] = tentative_g
                    f_score = tentative_g + neighbor.chebyshev_distance(goal)
                    heapq.heappush(open_set, (f_score, neighbor))

        # No path found
        return PathResult(
            found=False, path=[], directions=[], room_ids=[],
            total_cost=0, nodes_explored=nodes_explored
        )

    def _reconstruct_path(
        self,
        came_from: dict[GridCoord, GridCoord],
        current: GridCoord,
    ) -> list[GridCoord]:
        """Reconstruct path from A* came_from map."""
        path = [current]
        while current in came_from:
            current = came_from[current]
            path.append(current)
        path.reverse()
        return path

    def _path_to_directions(self, path: list[GridCoord]) -> list[str]:
        """Convert coordinate path to list of directions."""
        directions = []
        for i in range(len(path) - 1):
            direction = path[i].direction_to(path[i + 1])
            if direction:
                directions.append(direction)
        return directions

    # =========================================================================
    # Blocking
    # =========================================================================

    def block_coordinate(self, x: int, y: int, z: int = 0) -> None:
        """Mark a coordinate as blocked (impassable, no room)."""
        coord = GridCoord(x, y, z)
        if coord in self._grid:
            raise ValueError(f"Cannot block {coord}: room exists")
        self._blocked.add(coord)

    def unblock_coordinate(self, x: int, y: int, z: int = 0) -> None:
        """Remove block from coordinate."""
        coord = GridCoord(x, y, z)
        self._blocked.discard(coord)

    def is_blocked(self, x: int, y: int, z: int = 0) -> bool:
        """Check if coordinate is blocked."""
        return GridCoord(x, y, z) in self._blocked

    # =========================================================================
    # Bulk Operations
    # =========================================================================

    def create_grid_area(
        self,
        min_x: int,
        min_y: int,
        max_x: int,
        max_y: int,
        z: int = 0,
        room_factory: Callable[[int, int, int], UUID] | None = None,
        blocked_coords: set[tuple[int, int]] | None = None,
    ) -> dict[GridCoord, UUID]:
        """Create a rectangular grid of rooms.

        Args:
            min_x, min_y, max_x, max_y: Grid boundaries
            z: Z level
            room_factory: Function to create room entities
            blocked_coords: Set of (x, y) that should be blocked

        Returns:
            Mapping of coordinates to created room IDs
        """
        created = {}
        blocked = blocked_coords or set()

        for x in range(min_x, max_x + 1):
            for y in range(min_y, max_y + 1):
                if (x, y) in blocked:
                    self.block_coordinate(x, y, z)
                    continue

                if room_factory:
                    room_id = room_factory(x, y, z)
                    grid_room = self.register_room(room_id, x, y, z)
                    created[grid_room.coord] = room_id

        return created

    # =========================================================================
    # Serialization
    # =========================================================================

    def export_grid(self) -> dict[str, Any]:
        """Export grid data for serialization."""
        return {
            "rooms": [
                {
                    "room_id": str(gr.room_id),
                    "x": gr.coord.x,
                    "y": gr.coord.y,
                    "z": gr.coord.z,
                    "movement_cost": gr.movement_cost,
                    "terrain_type": gr.terrain_type,
                }
                for gr in self._grid.values()
            ],
            "blocked": [
                {"x": c.x, "y": c.y, "z": c.z}
                for c in self._blocked
            ],
            "config": {
                "auto_create_exits": self.auto_create_exits,
                "allow_diagonals": self.allow_diagonals,
                "allow_vertical": self.allow_vertical,
            }
        }

    def import_grid(self, data: dict[str, Any]) -> None:
        """Import grid data from serialization."""
        # Clear existing
        self._grid.clear()
        self._room_to_grid.clear()
        self._blocked.clear()

        # Load config
        config = data.get("config", {})
        self.auto_create_exits = config.get("auto_create_exits", True)
        self.allow_diagonals = config.get("allow_diagonals", True)
        self.allow_vertical = config.get("allow_vertical", True)

        # Load blocked
        for b in data.get("blocked", []):
            self.block_coordinate(b["x"], b["y"], b.get("z", 0))

        # Load rooms (without auto-connect to avoid missing rooms)
        for r in data.get("rooms", []):
            self.register_room(
                room_id=UUID(r["room_id"]),
                x=r["x"],
                y=r["y"],
                z=r.get("z", 0),
                movement_cost=r.get("movement_cost", 1.0),
                terrain_type=r.get("terrain_type", "default"),
                auto_connect=False,
            )

        # Now connect all rooms
        if self.auto_create_exits:
            for grid_room in self._grid.values():
                self._connect_to_neighbors(grid_room)

1.5 Integration with World

# Additions to packages/maid-engine/src/maid_engine/core/world.py

class World:
    """Extended with grid support."""

    def __init__(self, world_id: str = "default"):
        # ... existing init ...
        self.grid: GridManager = GridManager(self)

    def register_room_at_coord(
        self,
        room_id: UUID,
        x: int,
        y: int,
        z: int = 0,
        **kwargs
    ) -> None:
        """Register room in both room registry and grid."""
        self.register_room(room_id)
        self.grid.register_room(room_id, x, y, z, **kwargs)

1.6 CLI Commands for Grid Building

# Builder commands for grid manipulation

@command("@grid.create", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("min_x", ArgumentType.INTEGER),
    ArgumentSpec("min_y", ArgumentType.INTEGER),
    ArgumentSpec("max_x", ArgumentType.INTEGER),
    ArgumentSpec("max_y", ArgumentType.INTEGER),
    ArgumentSpec("z", ArgumentType.INTEGER, required=False, default=0),
)
async def cmd_grid_create(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Create a grid of empty rooms.

    Usage: @grid.create <min_x> <min_y> <max_x> <max_y> [z]
    Example: @grid.create 0 0 10 10 0
    """
    ...

@command("@grid.path", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("target", ArgumentType.STRING, description="Room name or coordinates"),
)
async def cmd_grid_path(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Find path from current room to target.

    Usage: @grid.path <target>
    """
    ...

@command("@grid.map", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("radius", ArgumentType.INTEGER, required=False, default=5),
)
async def cmd_grid_map(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Display ASCII map of nearby grid rooms.

    Usage: @grid.map [radius]
    """
    ...

1.7 Acceptance Criteria

ID Criterion Verification
AC-1.1 Rooms can be registered at coordinates Unit test
AC-1.2 Duplicate coordinates are rejected Unit test
AC-1.3 Adjacent rooms automatically get exits Integration test
AC-1.4 A* finds shortest path between rooms Unit test with known paths
AC-1.5 Pathfinding respects blocked cells Unit test
AC-1.6 Radius query returns correct rooms Unit test
AC-1.7 Grid coexists with non-coordinate rooms Integration test
AC-1.8 Path directions are correct Unit test
AC-1.9 Movement cost affects path selection Unit test
AC-1.10 Grid can be exported and imported Unit test

Feature 2: Wilderness/Procedural Generation

2.1 Feature Overview

What it does:
Provides on-demand procedural generation of wilderness rooms. When a player moves to a coordinate that doesn't have a room, the system generates one based on terrain rules, biomes, and noise functions. Generated rooms can be cached or discarded after players leave.

Why it's needed: - Current state: All rooms must be pre-created and stored - Cannot have truly large wilderness areas (would require millions of rooms) - Evennia's wilderness contrib provides procedural room generation

2.2 User Stories

US-2.1: Infinite Wilderness

As a game designer, I want players to explore an effectively infinite wilderness so that exploration feels vast and unending.

US-2.2: Biome Generation

As a game designer, I want different biomes (forest, desert, mountains) based on coordinates so that the world has geographic variety.

US-2.3: Consistent Generation

As a game designer, I want the same coordinates to always generate the same room so that players can share locations and return to places.

US-2.4: Resource Spawning

As a game designer, I want resources and encounters spawned based on terrain so that wilderness exploration is rewarding.

US-2.5: Memory Management

As a server administrator, I want generated rooms cleaned up when empty so that the server doesn't run out of memory.

US-2.6: Landmarks

As a game designer, I want to place fixed landmarks (dungeons, towns) in the procedural wilderness so that there are points of interest.

2.3 Technical Requirements

2.3.1 Core Requirements

ID Requirement Priority
WILD-001 System SHALL generate rooms on-demand when player moves to new coordinate P0
WILD-002 System SHALL use deterministic generation (same coord = same room) P0
WILD-003 System SHALL support multiple biomes with transitions P0
WILD-004 System SHALL clean up empty generated rooms after timeout P1
WILD-005 System SHALL support fixed landmark rooms that override generation P0
WILD-006 System SHALL generate appropriate exits to adjacent coordinates P0
WILD-007 System SHALL support resource/encounter placement P1
WILD-008 System SHALL support generation seeds for reproducibility P1
WILD-009 System SHALL support height maps for terrain elevation P2
WILD-010 System SHALL support generation boundaries (world edges) P1

2.3.2 Biome Requirements

ID Requirement Priority
BIO-001 System SHALL support configurable biome types P0
BIO-002 System SHALL determine biome from coordinate using noise P0
BIO-003 System SHALL generate biome-appropriate descriptions P0
BIO-004 System SHALL support biome-specific movement costs P1
BIO-005 System SHALL support biome-specific encounter tables P1
BIO-006 System SHALL support smooth biome transitions P2

2.4 API/Interface Design

2.4.1 Noise and Terrain Generation

# packages/maid-engine/src/maid_engine/world/procedural/noise.py

from __future__ import annotations

import math
from dataclasses import dataclass
from typing import Callable


class SimplexNoise:
    """Simplex noise implementation for procedural generation.

    Provides coherent noise values for any coordinate, deterministically.
    """

    def __init__(self, seed: int = 0):
        self.seed = seed
        self._perm = self._generate_permutation(seed)

    def _generate_permutation(self, seed: int) -> list[int]:
        """Generate permutation table from seed."""
        import random
        rng = random.Random(seed)
        perm = list(range(256))
        rng.shuffle(perm)
        return perm * 2  # Duplicate for overflow

    def noise2d(self, x: float, y: float) -> float:
        """Get 2D noise value at coordinate. Returns -1 to 1."""
        # Simplified Perlin-style noise implementation
        # Real implementation would use proper simplex algorithm

        def fade(t: float) -> float:
            return t * t * t * (t * (t * 6 - 15) + 10)

        def lerp(a: float, b: float, t: float) -> float:
            return a + t * (b - a)

        def grad(hash: int, x: float, y: float) -> float:
            h = hash & 3
            if h == 0: return x + y
            if h == 1: return -x + y
            if h == 2: return x - y
            return -x - y

        # Grid coordinates
        xi = int(math.floor(x)) & 255
        yi = int(math.floor(y)) & 255

        # Relative position in grid cell
        xf = x - math.floor(x)
        yf = y - math.floor(y)

        # Fade curves
        u = fade(xf)
        v = fade(yf)

        # Hash coordinates
        p = self._perm
        aa = p[p[xi] + yi]
        ab = p[p[xi] + yi + 1]
        ba = p[p[xi + 1] + yi]
        bb = p[p[xi + 1] + yi + 1]

        # Blend
        x1 = lerp(grad(aa, xf, yf), grad(ba, xf - 1, yf), u)
        x2 = lerp(grad(ab, xf, yf - 1), grad(bb, xf - 1, yf - 1), u)

        return lerp(x1, x2, v)

    def octave_noise(
        self,
        x: float,
        y: float,
        octaves: int = 4,
        persistence: float = 0.5,
        lacunarity: float = 2.0,
    ) -> float:
        """Multi-octave noise for more natural terrain."""
        total = 0.0
        frequency = 1.0
        amplitude = 1.0
        max_value = 0.0

        for _ in range(octaves):
            total += self.noise2d(x * frequency, y * frequency) * amplitude
            max_value += amplitude
            amplitude *= persistence
            frequency *= lacunarity

        return total / max_value


@dataclass
class TerrainConfig:
    """Configuration for terrain generation."""
    seed: int = 42
    scale: float = 100.0  # Scale of noise (larger = smoother terrain)
    octaves: int = 4
    persistence: float = 0.5
    lacunarity: float = 2.0

    # Height thresholds
    water_level: float = -0.3
    sand_level: float = -0.1
    grass_level: float = 0.3
    forest_level: float = 0.5
    mountain_level: float = 0.7


class TerrainGenerator:
    """Generates terrain types from coordinates."""

    def __init__(self, config: TerrainConfig | None = None):
        self.config = config or TerrainConfig()
        self.noise = SimplexNoise(self.config.seed)

        # Secondary noise for variation
        self.moisture_noise = SimplexNoise(self.config.seed + 1000)
        self.temperature_noise = SimplexNoise(self.config.seed + 2000)

    def get_terrain(self, x: int, y: int) -> str:
        """Get terrain type at coordinate."""
        # Get base elevation
        elevation = self.noise.octave_noise(
            x / self.config.scale,
            y / self.config.scale,
            octaves=self.config.octaves,
            persistence=self.config.persistence,
            lacunarity=self.config.lacunarity,
        )

        # Get moisture and temperature
        moisture = self.moisture_noise.octave_noise(
            x / (self.config.scale * 2),
            y / (self.config.scale * 2),
            octaves=3,
        )

        # Determine biome from elevation and moisture
        if elevation < self.config.water_level:
            return "water"
        elif elevation < self.config.sand_level:
            return "beach" if moisture < 0 else "swamp"
        elif elevation < self.config.grass_level:
            if moisture < -0.3:
                return "desert"
            elif moisture < 0.3:
                return "plains"
            else:
                return "grassland"
        elif elevation < self.config.forest_level:
            if moisture < 0:
                return "savanna"
            else:
                return "forest"
        elif elevation < self.config.mountain_level:
            if moisture < 0:
                return "hills"
            else:
                return "dense_forest"
        else:
            return "mountains"

    def get_elevation(self, x: int, y: int) -> float:
        """Get normalized elevation at coordinate."""
        return self.noise.octave_noise(
            x / self.config.scale,
            y / self.config.scale,
            octaves=self.config.octaves,
        )

2.4.2 Biome Definitions

# packages/maid-engine/src/maid_engine/world/procedural/biomes.py

from __future__ import annotations

from dataclasses import dataclass, field
from typing import Any, Callable
import random


@dataclass
class BiomeDefinition:
    """Definition of a biome type."""
    name: str
    display_name: str

    # Descriptions (randomly selected)
    descriptions: list[str]

    # Room name templates
    name_templates: list[str]

    # Movement properties
    movement_cost: float = 1.0
    sector_type: str = "FIELD"

    # Flags
    flags: set[str] = field(default_factory=set)

    # Resource spawning
    resource_types: list[str] = field(default_factory=list)
    resource_chance: float = 0.1

    # Encounter spawning
    encounter_types: list[str] = field(default_factory=list)
    encounter_chance: float = 0.05

    # Extra descriptions (random details)
    extra_details: list[str] = field(default_factory=list)

    def generate_name(self, x: int, y: int, rng: random.Random) -> str:
        """Generate room name for this biome."""
        template = rng.choice(self.name_templates)
        return template.format(x=x, y=y, biome=self.display_name)

    def generate_description(self, x: int, y: int, rng: random.Random) -> str:
        """Generate room description for this biome."""
        desc = rng.choice(self.descriptions)

        # Add random extra detail
        if self.extra_details and rng.random() < 0.5:
            detail = rng.choice(self.extra_details)
            desc = f"{desc} {detail}"

        return desc


# Default biome definitions
DEFAULT_BIOMES: dict[str, BiomeDefinition] = {
    "plains": BiomeDefinition(
        name="plains",
        display_name="Plains",
        descriptions=[
            "Rolling grasslands stretch to the horizon under an open sky.",
            "Tall grass sways gently in the breeze across the open plain.",
            "A flat expanse of green grass extends in all directions.",
        ],
        name_templates=[
            "Open Plains",
            "Grassy Field",
            "The Plains",
        ],
        movement_cost=1.0,
        sector_type="FIELD",
        resource_types=["herbs", "small_game"],
        extra_details=[
            "A small stream trickles nearby.",
            "Wildflowers dot the landscape.",
            "A lone tree stands in the distance.",
        ],
    ),
    "forest": BiomeDefinition(
        name="forest",
        display_name="Forest",
        descriptions=[
            "Tall trees form a dense canopy overhead, filtering the sunlight.",
            "You stand among ancient trees, their branches intertwining above.",
            "The forest is thick here, with undergrowth making travel difficult.",
        ],
        name_templates=[
            "Dense Forest",
            "Woodland Path",
            "Forest Depths",
        ],
        movement_cost=1.5,
        sector_type="FOREST",
        resource_types=["wood", "herbs", "mushrooms"],
        encounter_types=["wolf", "bear", "deer"],
        extra_details=[
            "Birds chirp in the branches above.",
            "A fallen log blocks part of the path.",
            "Mushrooms grow at the base of a tree.",
        ],
    ),
    "mountains": BiomeDefinition(
        name="mountains",
        display_name="Mountains",
        descriptions=[
            "Jagged peaks rise dramatically against the sky.",
            "Rocky terrain makes for difficult climbing.",
            "The mountain path winds between towering cliffs.",
        ],
        name_templates=[
            "Mountain Path",
            "Rocky Heights",
            "Mountain Slope",
        ],
        movement_cost=2.0,
        sector_type="MOUNTAIN",
        resource_types=["ore", "gems", "mountain_herbs"],
        encounter_types=["mountain_goat", "eagle", "troll"],
        extra_details=[
            "The wind howls through the peaks.",
            "Snow caps the distant summits.",
            "A narrow ledge offers a precarious passage.",
        ],
    ),
    "desert": BiomeDefinition(
        name="desert",
        display_name="Desert",
        descriptions=[
            "Endless dunes of golden sand stretch before you.",
            "The scorching sun beats down on the barren wasteland.",
            "Sand and rock dominate this desolate landscape.",
        ],
        name_templates=[
            "Sandy Dunes",
            "Desert Expanse",
            "Arid Wasteland",
        ],
        movement_cost=1.5,
        sector_type="DESERT",
        flags={"HOT"},
        resource_types=["cactus", "scorpion"],
        encounter_types=["snake", "scorpion", "sandworm"],
        extra_details=[
            "Heat mirages shimmer on the horizon.",
            "A bleached skeleton lies half-buried in sand.",
            "A lone cactus provides minimal shade.",
        ],
    ),
    "water": BiomeDefinition(
        name="water",
        display_name="Water",
        descriptions=[
            "Deep water blocks your path.",
            "The lake stretches out before you, its surface glittering.",
            "Waves lap gently against the shore.",
        ],
        name_templates=[
            "Lake Shore",
            "River Bank",
            "Waterside",
        ],
        movement_cost=999.0,  # Impassable without swimming/boat
        sector_type="WATER_NOSWIM",
        flags={"WATER"},
    ),
    "swamp": BiomeDefinition(
        name="swamp",
        display_name="Swamp",
        descriptions=[
            "Murky water and twisted trees create a treacherous marsh.",
            "The ground squelches underfoot as you navigate the bog.",
            "Thick fog hangs over the stagnant swamp waters.",
        ],
        name_templates=[
            "Murky Swamp",
            "Boggy Marsh",
            "Fetid Wetlands",
        ],
        movement_cost=2.5,
        sector_type="SWAMP",
        flags={"WET"},
        resource_types=["rare_herbs", "leeches"],
        encounter_types=["crocodile", "giant_frog", "swamp_thing"],
    ),
}

2.4.3 Wilderness Manager

# packages/maid-engine/src/maid_engine/world/procedural/wilderness.py

from __future__ import annotations

import asyncio
import random
import time
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Callable
from uuid import UUID, uuid4

from .noise import TerrainConfig, TerrainGenerator
from .biomes import BiomeDefinition, DEFAULT_BIOMES

if TYPE_CHECKING:
    from maid_engine.core.world import World
    from maid_engine.core.grid import GridManager


@dataclass
class GeneratedRoom:
    """A procedurally generated room."""
    room_id: UUID
    x: int
    y: int
    biome: str
    terrain_type: str
    created_at: float
    last_visited: float
    player_count: int = 0

    def is_stale(self, max_age_seconds: float) -> bool:
        """Check if room should be cleaned up."""
        if self.player_count > 0:
            return False
        return (time.time() - self.last_visited) > max_age_seconds


@dataclass
class Landmark:
    """A fixed location that overrides procedural generation."""
    room_id: UUID
    x: int
    y: int
    name: str
    radius: int = 0  # How many tiles around the landmark to also fix


@dataclass
class WildernessConfig:
    """Configuration for wilderness generation."""
    seed: int = 42
    terrain: TerrainConfig = field(default_factory=TerrainConfig)

    # Boundaries (None = infinite)
    min_x: int | None = None
    max_x: int | None = None
    min_y: int | None = None
    max_y: int | None = None

    # Cleanup
    cleanup_interval_seconds: float = 300.0  # How often to clean
    room_max_age_seconds: float = 600.0      # How long empty rooms last

    # Generation
    biomes: dict[str, BiomeDefinition] = field(default_factory=lambda: DEFAULT_BIOMES.copy())


class WildernessManager:
    """Manages procedural wilderness generation."""

    def __init__(
        self,
        world: World,
        grid: GridManager,
        config: WildernessConfig | None = None,
    ):
        self.world = world
        self.grid = grid
        self.config = config or WildernessConfig()

        self.terrain_gen = TerrainGenerator(self.config.terrain)

        # Generated rooms: (x, y) -> GeneratedRoom
        self._generated: dict[tuple[int, int], GeneratedRoom] = {}

        # Landmarks: (x, y) -> Landmark
        self._landmarks: dict[tuple[int, int], Landmark] = {}

        # Cleanup task
        self._cleanup_task: asyncio.Task | None = None

    async def start(self) -> None:
        """Start wilderness manager (cleanup task)."""
        self._cleanup_task = asyncio.create_task(self._cleanup_loop())

    async def stop(self) -> None:
        """Stop wilderness manager."""
        if self._cleanup_task:
            self._cleanup_task.cancel()

    # =========================================================================
    # Room Generation
    # =========================================================================

    def get_or_create_room(self, x: int, y: int) -> UUID:
        """Get existing room or generate new one at coordinates."""
        coord = (x, y)

        # Check for landmark
        if coord in self._landmarks:
            return self._landmarks[coord].room_id

        # Check for already generated
        if coord in self._generated:
            gen = self._generated[coord]
            gen.last_visited = time.time()
            return gen.room_id

        # Check boundaries
        if not self._in_bounds(x, y):
            raise ValueError(f"Coordinates ({x}, {y}) outside wilderness boundaries")

        # Generate new room
        return self._generate_room(x, y)

    def _generate_room(self, x: int, y: int) -> UUID:
        """Generate a new wilderness room."""
        # Deterministic RNG for this coordinate
        coord_seed = self.config.seed + x * 73856093 + y * 19349663
        rng = random.Random(coord_seed)

        # Get terrain type
        terrain_type = self.terrain_gen.get_terrain(x, y)
        biome_def = self.config.biomes.get(terrain_type)

        if not biome_def:
            biome_def = self.config.biomes["plains"]

        # Create room entity
        room_id = uuid4()

        # Generate room properties
        name = biome_def.generate_name(x, y, rng)
        description = biome_def.generate_description(x, y, rng)

        # Create room via world (this is simplified - actual implementation
        # would call into the room creation system)
        self._create_room_entity(
            room_id=room_id,
            name=name,
            description=description,
            sector_type=biome_def.sector_type,
            flags=biome_def.flags,
            movement_cost=biome_def.movement_cost,
        )

        # Register in grid
        self.grid.register_room(
            room_id=room_id,
            x=x,
            y=y,
            z=0,
            movement_cost=biome_def.movement_cost,
            terrain_type=terrain_type,
        )

        # Track generated room
        gen = GeneratedRoom(
            room_id=room_id,
            x=x,
            y=y,
            biome=terrain_type,
            terrain_type=terrain_type,
            created_at=time.time(),
            last_visited=time.time(),
        )
        self._generated[(x, y)] = gen

        # Spawn resources/encounters
        self._spawn_contents(room_id, biome_def, rng)

        return room_id

    def _create_room_entity(
        self,
        room_id: UUID,
        name: str,
        description: str,
        sector_type: str,
        flags: set[str],
        movement_cost: float,
    ) -> None:
        """Create room entity in world. (Implementation depends on Room model.)"""
        # This would create the actual room entity
        # Simplified for specification
        pass

    def _spawn_contents(
        self,
        room_id: UUID,
        biome: BiomeDefinition,
        rng: random.Random,
    ) -> None:
        """Spawn resources and encounters in room."""
        # Spawn resources
        if biome.resource_types and rng.random() < biome.resource_chance:
            resource_type = rng.choice(biome.resource_types)
            # Spawn resource entity
            pass

        # Spawn encounters
        if biome.encounter_types and rng.random() < biome.encounter_chance:
            encounter_type = rng.choice(biome.encounter_types)
            # Spawn encounter entity
            pass

    # =========================================================================
    # Landmarks
    # =========================================================================

    def add_landmark(
        self,
        room_id: UUID,
        x: int,
        y: int,
        name: str,
        radius: int = 0,
    ) -> None:
        """Add a fixed landmark that overrides generation."""
        landmark = Landmark(
            room_id=room_id,
            x=x,
            y=y,
            name=name,
            radius=radius,
        )

        # Add landmark and surrounding area
        for dx in range(-radius, radius + 1):
            for dy in range(-radius, radius + 1):
                coord = (x + dx, y + dy)
                if coord not in self._landmarks:
                    # Only the center has the actual room
                    if dx == 0 and dy == 0:
                        self._landmarks[coord] = landmark
                    # TODO: Handle landmark radius (might create connected rooms)

    def remove_landmark(self, x: int, y: int) -> None:
        """Remove a landmark."""
        coord = (x, y)
        if coord in self._landmarks:
            del self._landmarks[coord]

    # =========================================================================
    # Boundaries
    # =========================================================================

    def _in_bounds(self, x: int, y: int) -> bool:
        """Check if coordinates are within wilderness boundaries."""
        if self.config.min_x is not None and x < self.config.min_x:
            return False
        if self.config.max_x is not None and x > self.config.max_x:
            return False
        if self.config.min_y is not None and y < self.config.min_y:
            return False
        if self.config.max_y is not None and y > self.config.max_y:
            return False
        return True

    def get_edge_description(self, x: int, y: int, direction: str) -> str | None:
        """Get description for world edge if moving out of bounds."""
        # Check each direction for boundary
        dx, dy = 0, 0
        if direction == "north": dy = 1
        elif direction == "south": dy = -1
        elif direction == "east": dx = 1
        elif direction == "west": dx = -1

        new_x, new_y = x + dx, y + dy

        if not self._in_bounds(new_x, new_y):
            return "An impassable barrier blocks your way. You cannot travel further in that direction."

        return None

    # =========================================================================
    # Player Tracking
    # =========================================================================

    def player_entered(self, room_id: UUID) -> None:
        """Track player entering a generated room."""
        for gen in self._generated.values():
            if gen.room_id == room_id:
                gen.player_count += 1
                gen.last_visited = time.time()
                break

    def player_left(self, room_id: UUID) -> None:
        """Track player leaving a generated room."""
        for gen in self._generated.values():
            if gen.room_id == room_id:
                gen.player_count = max(0, gen.player_count - 1)
                break

    # =========================================================================
    # Cleanup
    # =========================================================================

    async def _cleanup_loop(self) -> None:
        """Periodically clean up stale generated rooms."""
        while True:
            await asyncio.sleep(self.config.cleanup_interval_seconds)
            await self._cleanup_stale_rooms()

    async def _cleanup_stale_rooms(self) -> None:
        """Remove generated rooms that are empty and old."""
        stale = [
            coord for coord, gen in self._generated.items()
            if gen.is_stale(self.config.room_max_age_seconds)
        ]

        for coord in stale:
            gen = self._generated.pop(coord)

            # Unregister from grid
            self.grid.unregister_room(gen.room_id)

            # Destroy room entity
            self.world.destroy_entity(gen.room_id)

    # =========================================================================
    # Queries
    # =========================================================================

    def preview_terrain(self, x: int, y: int, radius: int = 5) -> dict[tuple[int, int], str]:
        """Preview terrain types in an area without generating rooms."""
        result = {}
        for dx in range(-radius, radius + 1):
            for dy in range(-radius, radius + 1):
                px, py = x + dx, y + dy
                if self._in_bounds(px, py):
                    result[(px, py)] = self.terrain_gen.get_terrain(px, py)
        return result

    def get_stats(self) -> dict[str, Any]:
        """Get wilderness statistics."""
        return {
            "generated_rooms": len(self._generated),
            "landmarks": len(self._landmarks),
            "rooms_by_biome": self._count_by_biome(),
        }

    def _count_by_biome(self) -> dict[str, int]:
        """Count generated rooms by biome."""
        counts: dict[str, int] = {}
        for gen in self._generated.values():
            counts[gen.biome] = counts.get(gen.biome, 0) + 1
        return counts

2.5 Movement Integration

# Integration with movement system

async def handle_wilderness_movement(
    player_id: UUID,
    direction: str,
    world: World,
    wilderness: WildernessManager,
) -> bool:
    """Handle player movement in wilderness, generating rooms as needed."""
    # Get current position
    current_room = world.room_index.get_room(player_id)
    current_coord = world.grid.get_coord(current_room)

    if not current_coord:
        # Not in grid-based area
        return False

    # Calculate new position
    offset = GridCoord.from_direction(direction)
    new_coord = current_coord + offset

    # Check for boundary
    edge_desc = wilderness.get_edge_description(
        current_coord.x, current_coord.y, direction
    )
    if edge_desc:
        await player_send(player_id, edge_desc)
        return False

    # Get or create destination room
    try:
        dest_room = wilderness.get_or_create_room(new_coord.x, new_coord.y)
    except ValueError as e:
        await player_send(player_id, str(e))
        return False

    # Track player movement
    wilderness.player_left(current_room)

    # Move player
    await world.move_entity(player_id, dest_room)

    # Track arrival
    wilderness.player_entered(dest_room)

    return True

2.6 Acceptance Criteria

ID Criterion Verification
AC-2.1 Rooms generate on-demand when moving to new coordinates Integration test
AC-2.2 Same coordinates always produce same room (deterministic) Unit test
AC-2.3 Different biomes appear based on coordinate noise Visual inspection
AC-2.4 Landmarks override procedural generation Unit test
AC-2.5 Empty generated rooms are cleaned up after timeout Integration test
AC-2.6 Boundaries block movement with message Unit test
AC-2.7 Resources spawn with correct probability Statistical test
AC-2.8 Biome transitions are smooth Visual inspection
AC-2.9 Performance is acceptable (< 10ms per generation) Benchmark
AC-2.10 Preview shows terrain without generating rooms Unit test

Feature 3: Extended Room Features

3.1 Feature Overview

What it does:
Provides enhanced room descriptions with time-of-day variations, seasonal changes, weather effects, and dynamic detail systems. Rooms can have different appearances based on game state.

Why it's needed: - Current state: Rooms have static descriptions - No automatic variation based on time, weather, or seasons - Evennia's extended_room contrib provides dynamic room features

3.2 User Stories

US-3.1: Time-Based Descriptions

As a player, I want room descriptions to change based on time of day so that the world feels alive.

US-3.2: Seasonal Variations

As a player, I want rooms to reflect seasons so that I experience changing landscapes.

US-3.3: Weather Effects

As a player, I want weather to affect room descriptions so that storms and sunshine feel impactful.

US-3.4: Dynamic Details

As a world builder, I want to add conditional details that appear only sometimes so that rooms feel varied on revisit.

US-3.5: Mood Descriptions

As a game designer, I want rooms to have mood modifiers so that dungeons feel ominous and meadows feel peaceful.

3.3 Technical Requirements

ID Requirement Priority
EXT-001 System SHALL support time-of-day description variants P0
EXT-002 System SHALL support seasonal description variants P1
EXT-003 System SHALL integrate weather into descriptions P0
EXT-004 System SHALL support random detail injection P1
EXT-005 System SHALL support conditional descriptions P1
EXT-006 System SHALL support mood/atmosphere modifiers P2
EXT-007 System SHALL support exit descriptions that vary P1
EXT-008 System SHALL cache rendered descriptions for performance P1
EXT-009 System SHALL support ANSI color formatting P0
EXT-010 System SHALL support MXP tags in descriptions P1

3.4 API/Interface Design

3.4.1 Extended Room Model

# packages/maid-stdlib/src/maid_stdlib/components/extended_room.py

from __future__ import annotations

from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, Callable
import random

if TYPE_CHECKING:
    from maid_engine.core.ecs.entity import Entity


class TimeOfDay(Enum):
    """Time periods for description variation."""
    DAWN = auto()
    MORNING = auto()
    NOON = auto()
    AFTERNOON = auto()
    DUSK = auto()
    EVENING = auto()
    NIGHT = auto()
    MIDNIGHT = auto()


class Season(Enum):
    """Seasons for description variation."""
    SPRING = auto()
    SUMMER = auto()
    AUTUMN = auto()
    WINTER = auto()


class Weather(Enum):
    """Weather conditions."""
    CLEAR = auto()
    CLOUDY = auto()
    RAIN = auto()
    STORM = auto()
    SNOW = auto()
    FOG = auto()
    WIND = auto()


@dataclass
class RoomDetail:
    """A conditional detail that may appear in room description."""
    text: str
    weight: float = 1.0  # Probability weight
    conditions: dict[str, Any] = field(default_factory=dict)

    def check_conditions(self, context: dict[str, Any]) -> bool:
        """Check if detail should appear given context."""
        for key, required_value in self.conditions.items():
            if key not in context:
                return False

            actual_value = context[key]

            if isinstance(required_value, list):
                if actual_value not in required_value:
                    return False
            elif callable(required_value):
                if not required_value(actual_value):
                    return False
            elif actual_value != required_value:
                return False

        return True


@dataclass
class ExtendedDescriptions:
    """Extended description variants for a room."""

    # Base description (always shown)
    base: str = ""

    # Time-based variants (appended or replace)
    time_variants: dict[TimeOfDay, str] = field(default_factory=dict)
    time_mode: str = "append"  # "append" or "replace"

    # Seasonal variants
    season_variants: dict[Season, str] = field(default_factory=dict)
    season_mode: str = "append"

    # Weather effects (always appended)
    weather_effects: dict[Weather, str] = field(default_factory=dict)

    # Random details (selected based on weight)
    random_details: list[RoomDetail] = field(default_factory=list)
    max_random_details: int = 2

    # Conditional details (shown if conditions met)
    conditional_details: list[RoomDetail] = field(default_factory=list)

    # Mood/atmosphere
    mood: str | None = None
    mood_descriptions: dict[str, str] = field(default_factory=dict)

    def render(
        self,
        time_of_day: TimeOfDay,
        season: Season,
        weather: Weather,
        context: dict[str, Any] | None = None,
        rng: random.Random | None = None,
    ) -> str:
        """Render full room description based on current conditions."""
        rng = rng or random.Random()
        context = context or {}

        parts = []

        # Base or time variant
        if self.time_mode == "replace" and time_of_day in self.time_variants:
            parts.append(self.time_variants[time_of_day])
        else:
            parts.append(self.base)
            if time_of_day in self.time_variants:
                parts.append(self.time_variants[time_of_day])

        # Season variant
        if self.season_mode == "replace" and season in self.season_variants:
            parts[0] = self.season_variants[season]
        elif season in self.season_variants:
            parts.append(self.season_variants[season])

        # Weather effect
        if weather in self.weather_effects:
            parts.append(self.weather_effects[weather])

        # Mood
        if self.mood and self.mood in self.mood_descriptions:
            parts.append(self.mood_descriptions[self.mood])

        # Conditional details
        for detail in self.conditional_details:
            if detail.check_conditions(context):
                parts.append(detail.text)

        # Random details (weighted selection)
        eligible_details = [
            d for d in self.random_details
            if d.check_conditions(context)
        ]

        if eligible_details:
            # Weighted random selection
            total_weight = sum(d.weight for d in eligible_details)
            num_to_select = min(self.max_random_details, len(eligible_details))

            selected = []
            remaining = eligible_details.copy()

            for _ in range(num_to_select):
                if not remaining:
                    break

                r = rng.uniform(0, total_weight)
                cumulative = 0

                for i, detail in enumerate(remaining):
                    cumulative += detail.weight
                    if r <= cumulative:
                        selected.append(detail)
                        total_weight -= detail.weight
                        remaining.pop(i)
                        break

            for detail in selected:
                parts.append(detail.text)

        return " ".join(parts)


@dataclass
class ExtendedExitDescription:
    """Extended exit description with variations."""

    base: str = ""

    # State-based variants
    open_desc: str | None = None
    closed_desc: str | None = None
    locked_desc: str | None = None

    # Time variants
    time_variants: dict[TimeOfDay, str] = field(default_factory=dict)

    def render(
        self,
        is_open: bool,
        is_locked: bool,
        time_of_day: TimeOfDay,
    ) -> str:
        """Render exit description based on state."""
        # State-based
        if is_locked and self.locked_desc:
            desc = self.locked_desc
        elif not is_open and self.closed_desc:
            desc = self.closed_desc
        elif is_open and self.open_desc:
            desc = self.open_desc
        else:
            desc = self.base

        # Time variant
        if time_of_day in self.time_variants:
            desc = f"{desc} {self.time_variants[time_of_day]}"

        return desc

3.4.2 Extended Room Component

# packages/maid-stdlib/src/maid_stdlib/components/extended_room.py (continued)

from maid_engine.core.ecs.component import Component


class ExtendedRoomComponent(Component):
    """Component for extended room features."""

    def __init__(
        self,
        descriptions: ExtendedDescriptions | None = None,
        exit_descriptions: dict[str, ExtendedExitDescription] | None = None,
    ):
        super().__init__()
        self.descriptions = descriptions or ExtendedDescriptions()
        self.exit_descriptions = exit_descriptions or {}

        # Cache
        self._cached_description: str | None = None
        self._cache_key: tuple | None = None

    def get_description(
        self,
        time_of_day: TimeOfDay,
        season: Season,
        weather: Weather,
        context: dict[str, Any] | None = None,
        force_refresh: bool = False,
    ) -> str:
        """Get room description with caching."""
        cache_key = (time_of_day, season, weather, frozenset((context or {}).items()))

        if not force_refresh and self._cache_key == cache_key and self._cached_description:
            return self._cached_description

        # Render with fixed seed for determinism within same conditions
        seed = hash(cache_key) & 0xFFFFFFFF
        rng = random.Random(seed)

        self._cached_description = self.descriptions.render(
            time_of_day=time_of_day,
            season=season,
            weather=weather,
            context=context,
            rng=rng,
        )
        self._cache_key = cache_key

        return self._cached_description

    def get_exit_description(
        self,
        direction: str,
        is_open: bool,
        is_locked: bool,
        time_of_day: TimeOfDay,
    ) -> str:
        """Get exit description."""
        exit_desc = self.exit_descriptions.get(direction)
        if not exit_desc:
            return f"An exit leads {direction}."

        return exit_desc.render(is_open, is_locked, time_of_day)

    def invalidate_cache(self) -> None:
        """Invalidate cached description."""
        self._cached_description = None
        self._cache_key = None

3.4.3 Description Renderer

# packages/maid-stdlib/src/maid_stdlib/utils/room_renderer.py

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from maid_stdlib.components.extended_room import (
    ExtendedRoomComponent,
    TimeOfDay,
    Season,
    Weather,
)

if TYPE_CHECKING:
    from maid_engine.core.ecs.entity import Entity
    from maid_engine.core.world import World


class RoomRenderer:
    """Renders room descriptions with all dynamic elements."""

    def __init__(self, world: World):
        self.world = world

    def render_room(
        self,
        room_entity: Entity,
        viewer_entity: Entity | None = None,
        include_exits: bool = True,
        include_contents: bool = True,
    ) -> str:
        """Render complete room description for viewer."""
        parts = []

        # Get current conditions from world systems
        time_of_day = self._get_time_of_day()
        season = self._get_season()
        weather = self._get_weather(room_entity)

        # Build context for conditional rendering
        context = self._build_context(room_entity, viewer_entity)

        # Room name
        name = self._get_room_name(room_entity)
        parts.append(f"\n{self._format_room_name(name)}\n")

        # Main description
        extended = room_entity.get_component(ExtendedRoomComponent)
        if extended:
            desc = extended.get_description(time_of_day, season, weather, context)
        else:
            desc = self._get_basic_description(room_entity)

        parts.append(desc)

        # Exits
        if include_exits:
            exits_text = self._render_exits(room_entity, extended, time_of_day)
            if exits_text:
                parts.append(f"\n{exits_text}")

        # Contents (players, NPCs, items)
        if include_contents:
            contents_text = self._render_contents(room_entity, viewer_entity)
            if contents_text:
                parts.append(f"\n{contents_text}")

        return "\n".join(parts)

    def _get_time_of_day(self) -> TimeOfDay:
        """Get current time of day from GameTimeSystem."""
        # Implementation would query game time system
        return TimeOfDay.NOON

    def _get_season(self) -> Season:
        """Get current season from GameTimeSystem."""
        # Implementation would query game time system
        return Season.SUMMER

    def _get_weather(self, room_entity: Entity) -> Weather:
        """Get current weather for room from WeatherSystem."""
        # Implementation would query weather system for room's area
        return Weather.CLEAR

    def _build_context(
        self,
        room_entity: Entity,
        viewer_entity: Entity | None,
    ) -> dict[str, Any]:
        """Build context dict for conditional descriptions."""
        context = {
            "room_id": room_entity.id,
            "player_count": len(list(self.world.entities_in_room(room_entity.id))),
        }

        if viewer_entity:
            context["viewer_id"] = viewer_entity.id

            # Add viewer properties
            char = viewer_entity.get_component("CharacterComponent")
            if char:
                context["viewer_level"] = char.level
                context["viewer_race"] = char.race
                context["viewer_class"] = char.character_class

            # Add viewer flags
            context["viewer_flags"] = set(viewer_entity.tags)

        return context

    def _format_room_name(self, name: str) -> str:
        """Format room name with ANSI colors."""
        return f"\033[1;36m{name}\033[0m"  # Bold cyan

    def _get_room_name(self, room_entity: Entity) -> str:
        """Get room name from entity."""
        desc = room_entity.get_component("DescriptionComponent")
        return desc.name if desc else "Unknown Location"

    def _get_basic_description(self, room_entity: Entity) -> str:
        """Get basic room description (non-extended)."""
        desc = room_entity.get_component("DescriptionComponent")
        return desc.description if desc else "You see nothing special."

    def _render_exits(
        self,
        room_entity: Entity,
        extended: ExtendedRoomComponent | None,
        time_of_day: TimeOfDay,
    ) -> str:
        """Render exit descriptions."""
        room = room_entity.get_component("RoomComponent")
        if not room or not room.exits:
            return ""

        exit_parts = []
        for direction, exit_data in room.exits.items():
            if extended and direction in extended.exit_descriptions:
                is_open = not exit_data.door or exit_data.door.state == "OPEN"
                is_locked = exit_data.door and exit_data.door.state == "LOCKED"
                exit_desc = extended.get_exit_description(
                    direction, is_open, is_locked, time_of_day
                )
            else:
                exit_desc = f"An exit leads {direction}."

            exit_parts.append(exit_desc)

        return "\033[1;33mExits:\033[0m " + ", ".join(room.exits.keys())

    def _render_contents(
        self,
        room_entity: Entity,
        viewer_entity: Entity | None,
    ) -> str:
        """Render room contents (entities in room)."""
        parts = []

        for entity_id in self.world.entities_in_room(room_entity.id):
            if viewer_entity and entity_id == viewer_entity.id:
                continue  # Skip viewer

            entity = self.world.get_entity(entity_id)
            if not entity:
                continue

            # NPCs
            if entity.has_component("NPCComponent"):
                desc = entity.get_component("DescriptionComponent")
                name = desc.name if desc else "Someone"
                parts.append(f"  {name} is here.")

            # Other players
            elif entity.has_component("PlayerComponent"):
                player = entity.get_component("PlayerComponent")
                parts.append(f"  {player.name} is here.")

            # Items on ground
            elif entity.has_component("ItemComponent"):
                desc = entity.get_component("DescriptionComponent")
                name = desc.short_desc if desc else "Something"
                parts.append(f"  {name} is lying here.")

        return "\n".join(parts) if parts else ""

3.5 Builder Commands

@command("@room.desc.time", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("time", ArgumentType.STRING, choices=["dawn", "morning", "noon", "afternoon", "dusk", "evening", "night", "midnight"]),
    ArgumentSpec("description", ArgumentType.REST),
)
async def cmd_room_desc_time(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Set time-of-day description variant for current room.

    Usage: @room.desc.time <time> <description>
    Example: @room.desc.time night The room is shrouded in darkness.
    """
    ...

@command("@room.desc.season", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("season", ArgumentType.STRING, choices=["spring", "summer", "autumn", "winter"]),
    ArgumentSpec("description", ArgumentType.REST),
)
async def cmd_room_desc_season(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Set seasonal description variant for current room.

    Usage: @room.desc.season <season> <description>
    Example: @room.desc.season winter Snow blankets the ground.
    """
    ...

@command("@room.desc.weather", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("weather", ArgumentType.STRING, choices=["clear", "cloudy", "rain", "storm", "snow", "fog", "wind"]),
    ArgumentSpec("description", ArgumentType.REST),
)
async def cmd_room_desc_weather(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Set weather effect description for current room.

    Usage: @room.desc.weather <weather> <description>
    Example: @room.desc.weather rain Raindrops patter on the leaves.
    """
    ...

@command("@room.detail.add", category="builder", access_level=AccessLevel.BUILDER)
@arguments(
    ArgumentSpec("text", ArgumentType.REST),
)
async def cmd_room_detail_add(ctx: CommandContext, args: ParsedArguments) -> bool:
    """Add a random detail to current room.

    Usage: @room.detail.add <text>
    Example: @room.detail.add A bird chirps nearby.
    """
    ...

3.6 Acceptance Criteria

ID Criterion Verification
AC-3.1 Descriptions change based on time of day Manual test through day cycle
AC-3.2 Descriptions change based on season Manual test through seasons
AC-3.3 Weather effects appear in descriptions Manual test with weather changes
AC-3.4 Random details vary on each view (with caching) Unit test
AC-3.5 Conditional details appear when conditions met Unit test
AC-3.6 Exit descriptions reflect door state Unit test
AC-3.7 Description caching improves performance Benchmark
AC-3.8 Builder commands modify extended descriptions Integration test
AC-3.9 ANSI colors render correctly Visual test
AC-3.10 Non-extended rooms still work normally Regression test

Appendix A: Performance Considerations

Grid System Performance

Operation Target Implementation Notes
Point lookup O(1) Dict-based indexing
Radius query O(r²) Bounded by radius
A* pathfinding O(n log n) With priority queue
Room registration O(k) k = number of neighbors

Wilderness Performance

Operation Target Implementation Notes
Room generation < 10ms Cached noise, simple templates
Terrain query O(1) Noise is deterministic
Cleanup pass O(n) n = generated rooms
Memory per room < 1KB Minimal stored state

Description Rendering

Operation Target Implementation Notes
Cache hit < 1ms Return cached string
Cache miss < 5ms Full re-render
Cache key generation O(1) Tuple hashing

Appendix B: Testing Requirements

Unit Test Coverage

Component Minimum Coverage Key Test Cases
GridCoord 95% Arithmetic, distance, neighbors
GridManager 90% Registration, queries, pathfinding
A* Algorithm 95% Path finding, blocking, costs
SimplexNoise 90% Determinism, range
TerrainGenerator 85% Biome selection
WildernessManager 90% Generation, cleanup, landmarks
ExtendedDescriptions 90% All render paths
RoomRenderer 85% Full rendering

Integration Test Scenarios

  1. Grid + Movement: Register grid rooms, move player, verify pathfinding works
  2. Wilderness Exploration: Move through wilderness, verify generation, return to same spot
  3. Wilderness Cleanup: Generate rooms, wait for timeout, verify cleanup
  4. Extended Descriptions: Create room with all variants, cycle through conditions
  5. Builder Commands: Use all builder commands, verify changes persist

Performance Benchmarks

Operation Target Measurement
Grid registration (1000 rooms) < 1s pytest-benchmark
A* path (100 steps) < 10ms pytest-benchmark
Wilderness generation < 10ms per room pytest-benchmark
Description render (cached) < 1ms pytest-benchmark
Description render (full) < 5ms pytest-benchmark

Document History

Version Date Author Changes
1.0 2026-01-30 MAID Team Initial specification