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:
- XYZ Coordinate Grid System - Optional coordinate-based room indexing with pathfinding
- Wilderness/Procedural Generation - Dynamic room generation for large outdoor areas
- 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¶
- Feature 1: XYZ Coordinate Grid System
- Feature 2: Wilderness/Procedural Generation
- Feature 3: Extended Room Features
- Appendix A: Performance Considerations
- 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¶
- Grid + Movement: Register grid rooms, move player, verify pathfinding works
- Wilderness Exploration: Move through wilderness, verify generation, return to same spot
- Wilderness Cleanup: Generate rooms, wait for timeout, verify cleanup
- Extended Descriptions: Create room with all variants, cycle through conditions
- 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 |