Tier 1 — YAML-First Content Authoring¶
| Field | Value |
|---|---|
| Status | Draft |
| Authors | MAID Core Team |
| Created | 2025-07-14 |
| Package | maid-engine, maid-stdlib, maid-tutorial-world |
| Depends | Existing 6-phase loader pipeline, ContentPack protocol |
Terminology¶
This document uses the following terms consistently:
| Term | Definition |
|---|---|
| Content pack | A loadable unit with a manifest.toml / ContentPackManifest. May contain YAML data, Python code, or both. |
| Data pack | A content pack containing only YAML data and a manifest (no Python code). |
| Author | A person creating or editing YAML content files (the primary audience of this design). |
| Builder | A person using in-game building commands (@create, @dig, etc.) or the visual editor to modify the live world. |
| Zone | The formal authoring concept for a named grouping of rooms with shared metadata (level range, PvP rules, ambient settings). There is no dedicated zone entity type in the loader today — zone assignment is done via the room's zone top-level field in YAML (one of the allowed_top_level_fields on room EntityTypeConfig). The maid:zone:v1 schema shown in §6.7 is pack-provided. The runtime admin API exposes area_id / area_name fields on room responses (see maid_stdlib.api.admin.world), which correspond to the room's zone assignment. |
| Area | Has two usages: (1) Runtime metadata — the admin API includes area_id and area_name on room responses, reflecting the room's zone membership. area_id is a runtime-writable room metadata field backed by World area storage (not a read-only projection). (2) Tier 2 bundle document — a multi-entity document grouping related rooms, NPCs, and items for authoring convenience (see Tier 2 design). In both cases the underlying data concept is a Zone; new authoring content should use "Zone" as the canonical term. |
| Canonical format | The machine-readable, component-centric YAML format using _meta.schema and explicit components: mapping. This is the interchange and storage format used by @export, the proposed maid data export, and Tier ⅔ tooling. |
| Author-friendly format | A proposed authoring dialect (see §5) where top-level fields like name, health, and exits are normalized into the canonical component-centric format by the Assembly Layer. Until the Assembly Layer is implemented, only the canonical format is loadable. |
Table of Contents¶
- Executive Summary
- Design Principles
- Content Source of Truth & Reconciliation
- DataDrivenContentPack — New Base Class
- The Assembly Layer — Author-Friendly YAML to Components
- Standardized YAML Schemas
- JSON Schema Export & IDE Integration
- Enhanced Validation Rules Library
- Enhanced Quickstart & Scaffolding
- Tutorial World Migration Plan
- "Building Your First MUD" Guide Outline
- Mix-and-Match Architecture
- CLI Enhancements
- Implementation Plan
- Appendices
1. Executive Summary¶
MAID possesses a fully functional 6-phase YAML loader pipeline
(Discover → Parse → Prepare → ResolveRefs → Instantiate → PostLoad) with template
inheritance (_use, _extends), variable substitution (_vars / ${name:default}),
cross-entity references (@ref:), staged entity creation, source-map error reporting,
and semantic validation — yet neither shipped content pack uses it. The tutorial
world constructs its entire world graph imperatively across ~9,200 lines of Python
factory functions. The classic RPG pack uses bespoke manager classes with ad-hoc YAML
loaders. This design proposes DataDrivenContentPack, a new base class that
will auto-discover a data/ directory and feed it through the existing pipeline,
turning YAML into the primary authoring surface while preserving full Python
extensibility for systems, commands, and advanced logic. A proposed Assembly
Layer will be added to the PreparePhase to translate author-friendly YAML
(top-level name, health, npc fields) into the component-centric format the
loader expects. Alongside this, we standardize YAML schemas for the four built-in
with an extension mechanism for content packs to register additional types, expand the
validation rule library from 2 rules to 29, generate JSON Schema for IDE IntelliSense,
overhaul the quickstart scaffolder, migrate the tutorial world as reference
implementation, and extend the CLI with diff, watch, export, and init
commands — making MAID approachable for content authors who have never written Python.
2. Design Principles¶
2.1 YAML-First, Python-Flexible¶
YAML is the default authoring surface for rooms, NPCs, items, quests, and all static content. Python is reserved for behavior: systems, commands, event handlers, and custom pipeline phases. A content pack with zero Python beyond a manifest line is a first-class citizen.
2.2 Progressive Disclosure¶
A new author's first pack should require:
- A
manifest.toml(4 lines) - A
data/rooms.yamlfile (10 lines)
No pack.py, no __init__.py, no imports. As complexity grows, authors opt in to
Python: first for custom commands, then for systems, then for custom pipeline phases.
The framework never forces a cliff.
2.3 Backward Compatibility¶
Existing content packs — tutorial world, classic RPG, any community pack built on
BaseContentPack — continue to function without modification. DataDrivenContentPack
is a proposed alternative base class, not a replacement. BaseContentPack remains
unchanged.
2.4 Convention Over Configuration¶
The pipeline discovers files by directory structure:
data/
rooms.yaml → entity type "room" (inferred from filename)
rooms/ → entity type "room" (inferred from directory name)
village.yaml
forest.yaml
npcs.yaml → entity type "npc"
items/ → entity type "item"
weapons.yaml
potions.yaml
templates/ → entity type "template"
base_npc.yaml
No manifest of files. No registration calls. Drop a YAML file in the right place and
it loads. Override the convention with _meta.yaml when needed.
2.5 Fail-Fast with Helpful Errors¶
Every error includes:
- Rule ID (e.g.,
MAID-S012) — searchable in docs - File path and line number — via source maps (currently top-level key granularity; nested field-level source maps are a Phase 2 enhancement)
- Field path (e.g.,
rooms.tavern.exits.north.target) — when available - Human-readable message
- Suggestion — what to do about it (best-effort, not guaranteed for all errors)
ERROR MAID-S012 data/rooms.yaml:42 rooms.tavern.exits.north.target
Exit target '@ref:room/library' does not resolve to any known room.
→ Did you mean '@ref:room/village_library'? (Levenshtein distance: 2)
→ Available rooms: village_square, village_library, tavern, market
Note: Current source maps are top-level-key granularity (built via regex in
ParsePhase). Many errors today reportline=1orfield_path=None. Improving source-map depth is part of the Phase 2 implementation plan.
3. Content Source of Truth & Reconciliation¶
This section defines the canonical ownership model for content data across all authoring surfaces (YAML files, in-game building, visual editor, AI generation).
3.1 YAML Is the Canonical Source¶
For content loaded via the pipeline (and the proposed DataDrivenContentPack),
YAML files are the source of truth. Runtime entity state is derived from
YAML through the pipeline. This means:
- YAML → World is the primary data flow. Running
maid data loador starting the server creates entities from YAML definitions. - World → YAML is an explicit export operation (proposed
maid data export), not an automatic sync. - Runtime modifications (via in-game building commands like
@set,@describe) create instance drift — the live entity diverges from its YAML definition.
Canonical interchange format: The machine-readable, component-centric YAML
format with _meta.schema and explicit components: mapping is the canonical
interchange and storage format. This is what the proposed maid data export, @export, and
Tier ⅔ tooling produce and consume. The author-friendly YAML format (§5) is an
authoring dialect that the Assembly Layer normalizes into the canonical format
before the pipeline processes it.
Note on
allowed_top_level_fields: Some entity types accept specific top-level fields in the canonical component-centric format. For rooms,exitsandzoneare declared asallowed_top_level_fieldsonEntityTypeConfigand pass through the pipeline directly — they are not Assembly Layer fields and work today alongsidecomponents:. Similarly, content packs can declare their ownallowed_top_level_fieldsfor custom entity types.
3.2 Provenance Tracking¶
Every entity created by the pipeline receives a DataProvenanceComponent
(defined in maid_engine.loader.models):
class DataProvenanceComponent(Component):
"""Tracks authored definition provenance for instantiated entities."""
source_file: str # e.g., "data/rooms/village.yaml"
pack_name: str # e.g., "tutorial-world"
definition_id: str # entity ID from YAML
definition_hash: str # SHA-256 of the raw YAML definition
definition_version: str = "1.0" # schema version at load time
loaded_at: datetime # when this definition was last loaded
model_config = ConfigDict(frozen=True)
And an InstanceStateComponent for tracking runtime modifications:
class InstanceStateComponent(Component):
"""Tracks mutable runtime state modifications."""
modified: bool = False # True if any component changed at runtime
modified_components: set[str] = Field(default_factory=set)
last_modified_at: datetime | None = None
modified_by: str | None = None
3.3 Drift Detection¶
The proposed maid data diff command would compare YAML definitions against loaded entities by:
- Running the pipeline in preview mode (no instantiation) to produce
EntityDefinitionobjects withdefinition_hashvalues. - Comparing against
DataProvenanceComponent.definition_hashon loaded entities. - Checking
InstanceStateComponent.modifiedandmodified_componentsfor runtime drift.
3.4 Reconciliation Rules¶
| Scenario | Behavior |
|---|---|
| YAML unchanged, entity unchanged | No action |
| YAML changed, entity unchanged | Replace entity with new definition on reload |
| YAML unchanged, entity modified at runtime | Preserve runtime state (warn on proposed maid data diff) |
| YAML changed, entity modified at runtime | Conflict — maid data reload warns and skips; --force overwrites runtime state |
| Entity exists in world but not in YAML | Flagged as "orphan" by proposed maid data diff; not automatically removed |
| Entity in YAML but not in world | Created on next load |
3.5 Builder Edits and Export Workflow¶
When a builder modifies an entity via in-game commands (@set, @describe) or
a visual editor:
- The runtime entity is modified directly.
InstanceStateComponent.modifiedis set toTrue.- The change is not automatically written back to YAML.
- The author must explicitly run the proposed
maid data exportto materialize runtime state back to YAML files, creating a new canonical snapshot.
This "explicit export" model prevents accidental YAML drift and keeps the YAML files suitable for version control (git).
Prerequisite: The current
InstantiatePhasedoes not perform reconciliation with existing entities — it always creates new staged entities viaLoadTransaction. Implementing the reconciliation logic described above (hash comparison, skip-if-unchanged, conflict detection) is a Phase 1 prerequisite and is tracked in §14.1. Until reconciliation is implemented,maid data reloadwill replace all entities unconditionally.
4. DataDrivenContentPack — New Base Class¶
⚠️ PROPOSED DESIGN — NOT YET IMPLEMENTED
DataDrivenContentPackdoes not exist in the codebase today. Neither the class nor its hooks, context-construction logic, or zero-Python auto-discovery are implemented. This section describes the target design to be built in Phase 1 (see §14). All code samples in §4 use future tense and should be read as a specification, not documentation of current behavior.What exists today:
BaseContentPack(inmaid_engine.plugins.protocol) provides no-op defaults for allContentPackprotocol methods. Content packs extend it and implementon_load()with imperative Python code.
4.1 Design Goals¶
- Extend
BaseContentPackso all existing protocol methods work unchanged. - Auto-discover
data/directory relative to the subclass module file. - Run the 6-phase pipeline during
on_load, populating the world with entities. - Provide hooks at every stage for content packs that need customization.
- Construct a real
LoaderContextwithLoaderConfig, wiring all hooks into the context before running the pipeline.
4.2 Full Class Definition¶
"""Data-driven content pack base class.
Automatically discovers YAML/JSON content in a data/ directory and loads it
through the standard 6-phase pipeline. Subclasses need only define a manifest
and optionally override hooks for customization.
Location: packages/maid-engine/src/maid_engine/plugins/data_driven.py
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
from typing import Any
from maid_engine.core.engine import GameEngine
from maid_engine.loader.models import (
LoaderConfig,
LoaderContext,
LoadError,
ErrorSeverity,
)
from maid_engine.loader.pipeline import (
Phase,
Pipeline,
PipelineResult,
)
from maid_engine.loader.phases import (
DiscoverPhase,
ParsePhase,
PreparePhase,
ResolveRefsPhase,
InstantiatePhase,
PostLoadPhase,
)
from maid_engine.loader.rules import SemanticRule
from maid_engine.loader.entity_types import STANDARD_ENTITY_CONFIGS
from maid_engine.plugins.protocol import BaseContentPack
logger = logging.getLogger(__name__)
class DataDrivenContentPack(BaseContentPack):
"""Base class for content packs whose world content lives in YAML files.
Subclasses define a manifest property and optionally override hooks.
The data/ directory is discovered automatically relative to the module
that defines the subclass, or can be set explicitly via ``data_paths``.
Attributes:
_pipeline_result: Result of the last pipeline run, available after
on_load completes. Useful for inspection in tests.
"""
# -- Overridable class-level configuration --------------------------------
data_dir_name: str = "data"
"""Name of the data directory to discover. Override for non-standard layouts."""
pipeline_timeout: float = 60.0
"""Maximum seconds the pipeline may run before aborting."""
strict_validation: bool = True
"""If True, unresolved @ref: warnings become errors (maps to LoaderConfig.strict_mode)."""
fail_on_warnings: bool = False
"""If True, any warning halts the load."""
# -- State ----------------------------------------------------------------
_pipeline_result: PipelineResult | None = None
# -- Data path discovery --------------------------------------------------
@property
def data_paths(self) -> list[Path]:
"""Return directories to scan for YAML content.
Discovery order:
1. Explicit override (subclass overrides this property)
2. ``data/`` sibling of the concrete subclass module file
3. Empty list (pack has no data directory — pipeline is skipped)
Override to supply explicit paths or add project-root fallback.
"""
for cls in type(self).__mro__:
if cls in (DataDrivenContentPack, BaseContentPack, object):
continue
module = cls.__module__
mod = sys.modules.get(module)
if mod and hasattr(mod, "__file__") and mod.__file__:
candidate = Path(mod.__file__).parent / self.data_dir_name
if candidate.is_dir():
return [candidate]
return []
# -- Hook: custom phases --------------------------------------------------
def custom_phases(self) -> list[Phase]:
"""Return additional pipeline phases to insert.
Override to inject custom phases. They are inserted between
PreparePhase and ResolveRefsPhase by default. Use
``phase_order()`` for full control.
"""
return []
def phase_order(self) -> list[Phase]:
"""Return the complete ordered list of pipeline phases.
The default order inserts ``custom_phases()`` between Prepare
and ResolveRefs:
Discover → Parse → Prepare → *custom* → ResolveRefs
→ Instantiate → PostLoad
Override for complete control over phase ordering.
"""
custom = self.custom_phases()
return [
DiscoverPhase(),
ParsePhase(),
PreparePhase(),
*custom,
ResolveRefsPhase(),
InstantiatePhase(),
PostLoadPhase(),
]
# -- Hook: custom validation rules ----------------------------------------
def custom_rules(self) -> list[SemanticRule]:
"""Return additional semantic validation rules.
These are placed into ``LoaderContext.semantic_rules``. ``PreparePhase``
automatically prepends ``BUILTIN_RULES``, so only return pack-specific
rules here — do not include builtins.
"""
return []
# -- Hook: custom entity type configs -------------------------------------
def custom_entity_type_configs(self) -> dict[str, Any]:
"""Return additional entity type configurations.
Merged into ``LoaderContext.entity_type_configs`` alongside the
standard types (room, npc, item, template). Override to register
pack-specific entity types like spell, quest, monster.
Returns:
Mapping of type name → EntityTypeConfig.
"""
return {}
# -- Hook: pipeline context extras ----------------------------------------
def pipeline_context_extras(self) -> dict[str, Any]:
"""Return extra key-value pairs to pass to custom pipeline phases.
.. note::
``LoaderContext`` is ``@dataclass(slots=True)`` and does **not**
accept arbitrary attributes. The proposed implementation will add
a ``pack_extras: dict[str, Any]`` field to ``LoaderContext``
(defaulting to an empty dict) so that custom phases can access
pack-specific data. Until that field is added, this hook has no
effect.
"""
return {}
# -- Hook: before / after pipeline ----------------------------------------
async def on_before_pipeline(self, engine: GameEngine) -> None:
"""Called before the pipeline runs.
Override to register custom component types, schemas, or perform
any setup that the pipeline depends on. The engine and world are
fully initialized at this point.
"""
async def on_after_pipeline(
self, engine: GameEngine, result: PipelineResult
) -> None:
"""Called after the pipeline completes successfully.
Override to wire up cross-entity references, register additional
NPC behaviors, start background tasks, etc. The ``result``
contains entity counts and any warnings.
"""
async def on_pipeline_failure(
self, engine: GameEngine, result: PipelineResult
) -> None:
"""Called when the pipeline fails (has errors).
Override to implement custom error handling or fallback logic.
The default raises RuntimeError with the error summary.
"""
error_summary = "\n".join(
f" {e.code} {e.file_path}:{e.line} {e.message}"
for e in result.errors[:10]
)
remaining = len(result.errors) - 10
if remaining > 0:
error_summary += f"\n ... and {remaining} more errors"
raise RuntimeError(
f"Pipeline failed for pack '{self.manifest.name}' "
f"with {len(result.errors)} error(s):\n{error_summary}"
)
# -- Context construction -------------------------------------------------
def _build_loader_context(self, engine: GameEngine, paths: list[Path]) -> LoaderContext:
"""Construct a LoaderContext wiring all hooks into the pipeline.
This is where ``strict_validation``, ``custom_rules()``,
``custom_entity_type_configs()``, and ``pipeline_context_extras()``
are applied.
"""
config = LoaderConfig(
strict_mode=self.strict_validation,
pipeline_timeout=self.pipeline_timeout,
)
# Merge standard + custom entity type configs
entity_type_configs = dict(STANDARD_ENTITY_CONFIGS)
entity_type_configs.update(self.custom_entity_type_configs())
# Custom semantic rules only — BUILTIN_RULES are already prepended
# by PreparePhase: rule_set = [*BUILTIN_RULES, *context.semantic_rules]
custom_rules: list[SemanticRule] = list(self.custom_rules())
context = LoaderContext(
world=engine.world,
config=config,
reference_registry=engine.reference_registry,
data_paths=[Path(p) for p in paths],
pack_name=self.manifest.name,
entity_type_configs=entity_type_configs,
semantic_rules=custom_rules,
)
# NOTE: LoaderContext is @dataclass(slots=True). Storing pack extras
# requires adding a `pack_extras: dict[str, Any]` field to the class.
# Until that field exists, pipeline_context_extras() is a no-op.
return context
# -- Core on_load implementation ------------------------------------------
async def on_load(self, engine: GameEngine) -> None:
"""Load content by running the YAML pipeline.
This is the main entry point called by the engine. It:
1. Calls ``on_before_pipeline``
2. Discovers data paths
3. Builds a ``LoaderContext`` with ``LoaderConfig``
4. Constructs and runs the pipeline with the context
5. On success, calls ``on_after_pipeline``
6. On failure, calls ``on_pipeline_failure``
"""
await self.on_before_pipeline(engine)
paths = self.data_paths
if not paths:
logger.info(
"Pack '%s' has no data directory; skipping pipeline.",
self.manifest.name,
)
return
logger.info(
"Loading pack '%s' from %d data path(s): %s",
self.manifest.name,
len(paths),
", ".join(str(p) for p in paths),
)
# Build LoaderContext with all hooks wired in
context = self._build_loader_context(engine, paths)
# Build pipeline with configured phases and the context as default
pipeline = Pipeline(
phases=self.phase_order(),
timeout=self.pipeline_timeout,
default_context=context,
)
# Run pipeline — uses the default_context we set above
result = await pipeline.run_for_pack(
pack=self,
data_paths=[str(p) for p in paths],
context=context,
)
self._pipeline_result = result
# Check for warnings treated as errors
if self.fail_on_warnings and result.warnings:
result = PipelineResult(
success=False,
errors=result.errors + result.warnings,
warnings=[],
phase_results=result.phase_results,
entity_count=result.entity_count,
duration_ms=result.duration_ms,
)
if result.success:
logger.info(
"Pack '%s' loaded successfully: %d entities in %.1fms",
self.manifest.name,
result.entity_count,
result.duration_ms,
)
if result.warnings:
for w in result.warnings[:5]:
logger.warning(
" %s %s:%s %s", w.code, w.file_path, w.line, w.message
)
if len(result.warnings) > 5:
logger.warning(
" ... and %d more warnings",
len(result.warnings) - 5,
)
await self.on_after_pipeline(engine, result)
else:
await self.on_pipeline_failure(engine, result)
# -- Convenience accessors ------------------------------------------------
@property
def pipeline_result(self) -> PipelineResult | None:
"""The result of the most recent pipeline run, or None."""
return self._pipeline_result
@property
def loaded_entity_count(self) -> int:
"""Number of entities loaded by the last pipeline run."""
if self._pipeline_result:
return self._pipeline_result.entity_count
return 0
Key design decisions: The proposed
on_load()will construct a realLoaderContextwithLoaderConfig, wiringstrict_validation→config.strict_mode,custom_rules()→context.semantic_rules(custom only;PreparePhasealready prependsBUILTIN_RULES), andcustom_entity_type_configs()→context.entity_type_configs. ThePipelinewill receive the context viadefault_contextsorun_for_pack()does not raise.Note on reference registration: The current
PreparePhaseregisters symbolic refs directly into the globalReferenceRegistrybefore instantiation.LoadTransaction.track_reference()exists but is not wired into the staging flow. This means if later phases fail and the transaction rolls back, reference registrations fromPreparePhaseare not rolled back. Achieving fully transactional reference registration is a future enhancement tracked in §14.
4.3 Example Implementations¶
⚠️ PROPOSED — These examples use the proposed
DataDrivenContentPackclass which does not exist yet. The import pathmaid_engine.plugins.data_drivenis the intended module location once implemented.
Minimal Pack (10 lines of Python)¶
A pack that is pure YAML — no systems, no commands, no events. Just content:
# src/my_tavern/pack.py
from maid_engine.plugins.data_driven import DataDrivenContentPack
from maid_engine.plugins.manifest import ContentPackManifest
class TavernContentPack(DataDrivenContentPack):
"""A cozy tavern with rooms, NPCs, and items — all in YAML."""
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="tavern",
version="1.0.0",
description="A cozy tavern area",
)
def get_dependencies(self) -> list[str]:
return ["stdlib"]
With the accompanying data directory:
src/my_tavern/
pack.py ← 10 lines above
data/
rooms.yaml ← tavern rooms
npcs.yaml ← barkeeper, patrons
items.yaml ← drinks, food, keys
That's it. No __init__.py needed for the data directory. No imports beyond
the base class and manifest. The pipeline discovers data/, parses the YAML,
resolves references, instantiates entities, and populates the world.
Standard Pack (30 lines — with systems and commands)¶
A pack that adds custom behavior on top of YAML-defined content:
# src/haunted_manor/pack.py
from maid_engine.core.ecs import System
from maid_engine.core.world import World
from maid_engine.plugins.data_driven import DataDrivenContentPack
from maid_engine.plugins.manifest import ContentPackManifest
from maid_engine.commands.registry import CommandRegistry, LayeredCommandRegistry
from .systems.haunting import HauntingSystem
from .systems.ghostly_sounds import GhostlySoundsSystem
from .commands import register_commands
from .events import GhostAppearEvent, HauntingEvent
class HauntedManorContentPack(DataDrivenContentPack):
"""A haunted manor with ghostly encounters and puzzles."""
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="haunted-manor",
version="1.0.0",
description="A haunted manor with ghosts and puzzles",
dependencies={"stdlib": ">=1.0.0"},
)
def get_dependencies(self) -> list[str]:
return ["stdlib"]
def get_systems(self, world: World) -> list[System]:
return [HauntingSystem(world), GhostlySoundsSystem(world)]
def get_events(self) -> list[type]:
return [GhostAppearEvent, HauntingEvent]
def register_commands(
self, registry: CommandRegistry | LayeredCommandRegistry
) -> None:
register_commands(registry)
The HauntingSystem operates on entities loaded from YAML (ghosts with
NPCComponent, rooms with custom HauntedComponent). The YAML defines what
exists; the Python defines what happens.
Advanced Pack (with custom phases and post-load wiring)¶
A pack that extends the pipeline itself:
# src/dynamic_ecology/pack.py
from __future__ import annotations
from typing import Any
from maid_engine.core.engine import GameEngine
from maid_engine.core.ecs import System
from maid_engine.core.world import World
from maid_engine.loader.pipeline import Phase, PipelineResult
from maid_engine.loader.rules import SemanticRule
from maid_engine.plugins.data_driven import DataDrivenContentPack
from maid_engine.plugins.manifest import ContentPackManifest
from .phases.ecology_validation import EcologyValidationPhase
from .phases.food_chain_resolver import FoodChainResolverPhase
from .rules.ecology_rules import (
PredatorPreyBalanceRule,
HabitatOverlapRule,
SpawnDensityRule,
)
from .systems.ecology import EcologySystem
from .systems.migration import MigrationSystem
class DynamicEcologyContentPack(DataDrivenContentPack):
"""Ecology system with predator-prey dynamics and migration."""
pipeline_timeout: float = 120.0 # Ecology validation is expensive
strict_validation: bool = True
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="dynamic-ecology",
version="2.0.0",
description="Dynamic creature ecology with food chains",
dependencies={"stdlib": ">=1.0.0", "classic-rpg": ">=1.0.0"},
)
def get_dependencies(self) -> list[str]:
return ["stdlib", "classic-rpg"]
def get_systems(self, world: World) -> list[System]:
return [EcologySystem(world), MigrationSystem(world)]
# -- Pipeline customization -----------------------------------------------
def custom_phases(self) -> list[Phase]:
"""Insert ecology-specific phases between Prepare and ResolveRefs."""
return [
FoodChainResolverPhase(),
EcologyValidationPhase(),
]
def custom_rules(self) -> list[SemanticRule]:
return [
PredatorPreyBalanceRule(),
HabitatOverlapRule(),
SpawnDensityRule(),
]
def pipeline_context_extras(self) -> dict[str, Any]:
return {
"ecology_config": {
"max_species_per_biome": 12,
"min_prey_ratio": 0.6,
}
}
# -- Post-load wiring -----------------------------------------------------
async def on_after_pipeline(
self, engine: GameEngine, result: PipelineResult
) -> None:
"""Wire up ecology event handlers after all creatures are loaded."""
world = engine.world
# Find all creatures by iterating entities and checking tags
creatures = [
e for e in world.get_all_entities()
if e.has_tag("creature")
]
logger.info("Building food chain for %d creature species", len(creatures))
# ... build food chain from component data ...
async def on_before_pipeline(self, engine: GameEngine) -> None:
"""Register ecology-specific component types."""
engine.component_registry.register(EcologyComponent)
engine.component_registry.register(MigrationComponent)
4.4 Backward Compatibility¶
The proposed DataDrivenContentPack will extend BaseContentPack. It will
not modify BaseContentPack or the ContentPack protocol. The intended
inheritance chain:
ContentPack (Protocol)
↑ satisfies
BaseContentPack (concrete, no-op defaults)
↑ extends
DataDrivenContentPack (concrete, pipeline-aware defaults)
↑ extends
YourContentPack (your code)
Existing packs that extend BaseContentPack directly are completely unaffected.
The engine's load_content_pack() method accepts anything satisfying the
ContentPack protocol — it does not care which base class is used.
4.5 Zero-Python Data Packs via Directory Discovery¶
⚠️ PROPOSED — Not yet implemented.
ContentPackLoader._load_from_directory()currently requires apack.pyfile. Ifpack.pyis missing, the loader returnsNoneand the pack is not loaded. The auto-generation behavior described below will be implemented as part of Phase 1.
For the simplest possible authoring experience, ContentPackLoader will be
extended to support data packs that have no pack.py at all:
When ContentPackLoader._load_from_directory() finds a manifest.toml but no
pack.py, it will auto-generate a DataDrivenContentPack instance using the manifest:
# Inside ContentPackLoader._load_from_directory():
if not pack_py_path.exists():
manifest = ContentPackManifest.from_toml(manifest_path)
pack = DataDrivenContentPack()
pack._manifest = manifest
pack._explicit_data_paths = [pack_dir / "data"]
return pack
This means a content author can create a complete data pack with zero Python.
5. The Assembly Layer — Author-Friendly YAML to Components¶
⚠️ PROPOSED DESIGN — NOT YET IMPLEMENTED
The Assembly Layer described in this section does not exist in the current codebase.
PreparePhasereadscomponents:directly and has no field-mapping step.EntityAssemblerexists inmaid_engine.loader.assemblerbut is not invoked by any pipeline phase. The assembly rules, field mappings, and normalization logic below describe the target design to be built in Phase 2 (see §14).What works today: Entities must use the component-centric format with an explicit
components:mapping (see §5.1, "What the loader expects today").
5.1 The Problem¶
The current pipeline's PreparePhase expects entities in a component-centric
format:
# What the loader expects today (component-centric)
rooms:
village_square:
_id: village_square
components:
DescriptionComponent:
name: "Village Square"
short_desc: "A bustling village square."
long_desc: "The heart of the village..."
keywords: [square, fountain]
ExtendedRoomComponent:
descriptions:
time_variants:
dawn: "Golden light..."
atmosphere_text: "Birds chirp."
exits:
north: "@ref:room/forest_path"
zone: village
This is verbose and requires authors to know exact component class names. We want authors to write:
# What authors write (author-friendly)
rooms:
village_square:
name: "Village Square"
short_desc: "A bustling village square."
long_desc: "The heart of the village..."
keywords: [square, fountain]
extended:
time_variants:
dawn: "Golden light..."
atmosphere_text: "Birds chirp."
exits:
north: "@ref:room/forest_path"
zone: village
5.2 The Assembly Layer¶
A new sub-step will be added to PreparePhase called the Assembly Layer. It
will run after template resolution and variable substitution, but before component
validation. Its job is to normalize author-friendly top-level fields into the
components: mapping that the rest of the pipeline expects.
PreparePhase execution order:
1. Resolve schema / entity type
2. Extract entities from parsed documents
3. Resolve templates and variables (_use, _extends, _vars)
4. ★ Assembly Layer — normalize author-friendly fields → components
5. Validate components
6. Assign UUIDs
7. Register references
8. Run semantic rules
5.3 Assembly Rules¶
The Assembly Layer will use a registry of assembly rules — one per entity type. Each rule defines how top-level fields map to component fields:
# packages/maid-engine/src/maid_engine/loader/assembly.py
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class FieldMapping:
"""Maps an author-friendly field to a component field."""
source_field: str # top-level YAML field name
component_name: str # target Component class name
component_field: str | None = None # field within component (None = use source_field)
@dataclass(frozen=True)
class AssemblyRule:
"""Defines how author-friendly YAML maps to components for an entity type."""
entity_type: str
field_mappings: list[FieldMapping]
nested_mappings: dict[str, str] = field(default_factory=dict)
# nested_mappings: author field → component name (entire object becomes component data)
# --- Standard assembly rules ---
ROOM_ASSEMBLY = AssemblyRule(
entity_type="room",
field_mappings=[
FieldMapping("name", "DescriptionComponent", "name"),
FieldMapping("short_desc", "DescriptionComponent", "short_desc"),
FieldMapping("long_desc", "DescriptionComponent", "long_desc"),
FieldMapping("keywords", "DescriptionComponent", "keywords"),
],
nested_mappings={
"extended": "ExtendedRoomComponent",
},
)
NPC_ASSEMBLY = AssemblyRule(
entity_type="npc",
field_mappings=[
FieldMapping("name", "DescriptionComponent", "name"),
FieldMapping("short_desc", "DescriptionComponent", "short_desc"),
FieldMapping("long_desc", "DescriptionComponent", "long_desc"),
FieldMapping("keywords", "DescriptionComponent", "keywords"),
],
nested_mappings={
"npc": "NPCComponent",
"stats": "StatsComponent",
"health": "HealthComponent",
"mana": "ManaComponent",
"combat": "CombatComponent",
"inventory": "InventoryComponent",
"dialogue": "DialogueComponent",
},
)
ITEM_ASSEMBLY = AssemblyRule(
entity_type="item",
field_mappings=[
FieldMapping("name", "DescriptionComponent", "name"),
FieldMapping("short_desc", "DescriptionComponent", "short_desc"),
FieldMapping("long_desc", "DescriptionComponent", "long_desc"),
FieldMapping("keywords", "DescriptionComponent", "keywords"),
],
nested_mappings={
"item": "ItemComponent",
"weapon": "WeaponComponent",
"armor": "ArmorComponent",
},
)
5.4 Assembly Algorithm¶
def assemble_entity(
raw: dict[str, Any],
rule: AssemblyRule,
) -> dict[str, Any]:
"""Transform author-friendly YAML into component-centric format.
If the entity already has a ``components`` key, it is assumed to be
in component-centric format and is returned unchanged (pass-through).
"""
# Pass-through: already component-centric
if "components" in raw:
return raw
components: dict[str, dict[str, Any]] = {}
# Apply field mappings (individual fields → component fields)
for mapping in rule.field_mappings:
if mapping.source_field in raw:
comp = components.setdefault(mapping.component_name, {})
target = mapping.component_field or mapping.source_field
comp[target] = raw.pop(mapping.source_field)
# Apply nested mappings (entire objects → component data)
for source_field, component_name in rule.nested_mappings.items():
if source_field in raw:
components[component_name] = raw.pop(source_field)
if components:
raw["components"] = components
return raw
5.5 Pass-Through for Component-Centric YAML¶
If an entity already contains a components: key, the Assembly Layer skips it
entirely. This means:
- Author-friendly format (top-level
name,health, etc.) is assembled. - Component-centric format (
components: {DescriptionComponent: ...}) passes through unchanged. - Both formats can coexist in the same file or across files in a pack.
This is critical for backward compatibility with any existing YAML content that uses the component-centric format (including output from AI generation tools in Tier 2).
5.6 Custom Assembly Rules from Content Packs¶
⚠️ PROPOSED —
EntityTypeConfigdoes not currently have anassembly_rulefield. Its fields are:type_name,required_components,default_components,default_tags, andallowed_top_level_fields. Addingassembly_rulerequires extending the dataclass as part of this design.
Content packs that register custom entity types can also register assembly
rules via the custom_entity_type_configs() hook. This design proposes adding
an optional assembly_rule field to EntityTypeConfig.
6. Standardized YAML Schemas¶
6.1 Schema Versioning¶
Every YAML entity file declares its schema under _meta.schema. The format is
maid:<type>:v<version>:
This aligns with the current PreparePhase._resolve_schema() implementation,
which reads doc.data.get("_meta").get("schema"). When _meta.schema is omitted,
the pipeline infers the entity type from the top-level key (the first key
that is not _meta, singular-ized by stripping a trailing s) and
cross-checks it against the parent directory name (also singular-ized).
If both are present and disagree, PreparePhase emits error MAID-PRE009.
If no top-level key exists, the directory name alone is used as a fallback.
The entity type alone is determined — version is not inferred. When
_meta.schema includes a version (e.g., maid:room:v1), that version is
preserved; when schema is inferred from key/directory, no version is attached.
Schema identifiers registered out of the box (the four built-in entity types):
| Schema ID | Entity Type | Required Components |
|---|---|---|
maid:room:v1 |
room | DescriptionComponent |
maid:npc:v1 |
npc | DescriptionComponent, NPCComponent |
maid:item:v1 |
item | DescriptionComponent, ItemComponent |
maid:template:v1 |
template | (none) |
Additional entity types (monster, quest, zone, spell, skill, shop) are not
built into the engine. Content packs register them via custom_entity_type_configs():
# Example: classic-rpg registering additional entity types
from maid_engine.loader.entity_types import EntityTypeConfig
def custom_entity_type_configs(self) -> dict[str, EntityTypeConfig]:
return {
"monster": EntityTypeConfig(
type_name="monster",
required_components=frozenset({
"DescriptionComponent", "NPCComponent",
"HealthComponent", "CombatComponent",
}),
default_tags=frozenset({"npc", "monster"}),
allowed_top_level_fields=frozenset({"behavior", "loot", "ecology"}),
),
"quest": EntityTypeConfig(
type_name="quest",
required_components=frozenset(),
default_tags=frozenset({"quest"}),
allowed_top_level_fields=frozenset({
"objectives", "rewards", "prerequisites",
"branches", "dialogue", "failure",
}),
),
"spell": EntityTypeConfig(
type_name="spell",
required_components=frozenset(),
default_tags=frozenset({"spell"}),
allowed_top_level_fields=frozenset({
"school", "element", "level", "mana_cost",
"casting", "effects", "requirements", "cooldown",
}),
),
# ... zone, skill, shop similarly ...
}
Scope note: The YAML examples in §6.5–§6.10 for monster, quest, spell, etc. are pack-provided examples — these entity types are registered by content packs (e.g.,
maid-classic-rpg), not by the engine. Only room, npc, item, and template work out of the box without a content pack providing the type config.Format note: The examples in §6.2–§6.4 use the author-friendly format (top-level
name,health, etc.) which requires the Assembly Layer (§5) to be implemented. Until then, use the component-centric format with an explicitcomponents:mapping. Both formats are shown for rooms (§6.2) and simple NPCs (§6.3). Other examples show only the author-friendly format for readability — see §6.2 for the pattern to convert any example to the component-centric format that works today.
6.2 Room Schema (maid:room:v1)¶
Simple Room¶
Author-friendly format (requires Assembly Layer — see §5):
# data/rooms.yaml
_meta:
schema: maid:room:v1
rooms:
village_square:
name: "Village Square"
short_desc: "A bustling village square with a stone fountain."
long_desc: |
The heart of the village. A weathered stone fountain burbles
at the center, surrounded by cobblestones worn smooth by
generations of foot traffic. Shops line the eastern edge,
while a dirt road leads north toward the forest.
keywords: [square, fountain, village]
exits:
north: "@ref:room/forest_path"
east: "@ref:room/market_lane"
south: "@ref:room/old_well"
west: "@ref:room/bakery"
zone: village
Component-centric format (works today):
# data/rooms.yaml
_meta:
schema: maid:room:v1
rooms:
village_square:
components:
DescriptionComponent:
name: "Village Square"
short_desc: "A bustling village square with a stone fountain."
long_desc: |
The heart of the village. A weathered stone fountain burbles
at the center, surrounded by cobblestones worn smooth by
generations of foot traffic. Shops line the eastern edge,
while a dirt road leads north toward the forest.
keywords: [square, fountain, village]
exits:
north: "@ref:room/forest_path"
east: "@ref:room/market_lane"
south: "@ref:room/old_well"
west: "@ref:room/bakery"
zone: village
Door Exits¶
treasury:
name: "The Treasury"
short_desc: "A vault behind a heavy iron door."
long_desc: "Gold coins and gemstones glitter on stone shelves."
exits:
north:
target: "@ref:room/throne_room"
door:
name: "iron door"
locked: true
key: "@ref:item/treasury_key"
difficulty: 25
description: "A massive iron door with three deadbolts."
south: "@ref:room/guard_hall"
Grid-Positioned Room¶
Note:
gridis not in the core room config'sallowed_top_level_fields(which only allowsexitsandzone). Grid positioning is provided byWorldSystemsContentPackand requires that pack to registergridas an allowed field or use thecomponents:format directly.
arena_center:
name: "Arena Center"
short_desc: "The blood-stained center of the fighting pit."
long_desc: "Sand crunches underfoot. The crowd roars."
components:
PositionComponent:
x: 0
y: 0
z: 0
exits:
north: "@ref:room/arena_north"
south: "@ref:room/arena_south"
Extended Room (time/weather/season variants)¶
forest_clearing:
name: "Forest Clearing"
short_desc: "A sun-dappled clearing in the ancient forest."
long_desc: "Wildflowers carpet the ground between towering oaks."
keywords: [clearing, forest, flowers]
# ExtendedRoomComponent fields (nested under descriptions:)
extended:
descriptions:
time_variants:
dawn: "Golden light filters through the canopy as birds begin their chorus."
morning: "Sunlight streams through gaps in the leaves overhead."
dusk: "The clearing glows amber as the sun dips below the treeline."
night: "Moonlight turns the clearing into a silver pool of stillness."
season_variants:
spring: "Wildflowers burst from every inch of ground in riotous color."
summer: "The thick canopy provides cool shade from the blazing sun."
autumn: "A carpet of crimson and gold leaves crunches underfoot."
winter: "Bare branches sketch dark lines against a pewter sky."
weather_effects:
rain: "Raindrops patter on leaves, turning the clearing to mud."
storm: "Thunder shakes the trees and lightning reveals stark shadows."
fog: "A thick fog rolls between the trunks, hiding everything beyond arm's reach."
snow: "A pristine blanket of snow muffles all sound."
mood: peaceful
atmosphere_text: "The air smells of pine needles and damp earth."
random_details:
- text: "A family of rabbits nibbles at the wildflowers."
weight: 3
- text: "A hawk circles far overhead."
weight: 2
conditional_details:
- text: "You notice old stone markers half-buried in the undergrowth."
weight: 1
conditions:
perception: 15
exits:
north: "@ref:room/deep_forest"
south: "@ref:room/forest_path"
east: "@ref:room/hidden_cave_entrance"
zone: forest
6.3 NPC Schema (maid:npc:v1)¶
Simple NPC¶
Author-friendly format (requires Assembly Layer — see §5):
# data/npcs.yaml
_meta:
schema: maid:npc:v1
npcs:
elder_miriam:
name: "Elder Miriam"
short_desc: "A wizened woman leaning on a gnarled staff."
long_desc: |
Elder Miriam's face is a map of deep wrinkles earned through
decades of village leadership. Her sharp blue eyes miss nothing.
A gnarled oak staff supports her slight frame.
keywords: [elder, miriam, woman, leader]
location: "@ref:room/village_square"
# NPCComponent fields
npc:
behavior_type: stationary
dialogue_id: elder_miriam_dialogue
faction_id: village
is_quest_giver: true
wander_radius: 0
respawn_time: 0 # permanent NPC, does not respawn
# Optional components
stats:
level: 10
strength: 8
dexterity: 6
constitution: 12
intelligence: 18
wisdom: 20
charisma: 16
health:
current: 80
maximum: 80
regeneration_rate: 0.5
tags: [quest_giver, village_elder, protected]
Component-centric format (works today):
# data/npcs.yaml
_meta:
schema: maid:npc:v1
npcs:
elder_miriam:
components:
DescriptionComponent:
name: "Elder Miriam"
short_desc: "A wizened woman leaning on a gnarled staff."
long_desc: |
Elder Miriam's face is a map of deep wrinkles earned through
decades of village leadership. Her sharp blue eyes miss nothing.
A gnarled oak staff supports her slight frame.
keywords: [elder, miriam, woman, leader]
NPCComponent:
behavior_type: stationary
dialogue_id: elder_miriam_dialogue
faction_id: village
is_quest_giver: true
wander_radius: 0
respawn_time: 0
StatsComponent:
level: 10
strength: 8
dexterity: 6
constitution: 12
intelligence: 18
wisdom: 20
charisma: 16
HealthComponent:
current: 80
maximum: 80
regeneration_rate: 0.5
location: "@ref:room/village_square"
tags: [quest_giver, village_elder, protected]
NPC with AI Dialogue¶
bartender_grok:
name: "Grok the Bartender"
short_desc: "A massive half-orc polishing a tankard."
long_desc: |
Grok towers over the bar, his green-tinged skin stretched over
muscles that could bend horseshoes. Despite his fearsome
appearance, his eyes are kind and his laugh booms warmly.
keywords: [grok, bartender, half-orc, barkeeper]
location: "@ref:room/tavern"
npc:
behavior_type: stationary
is_merchant: true
faction_id: merchants_guild
dialogue:
ai_enabled: true
provider_name: anthropic
personality: |
Friendly, garrulous half-orc bartender. Former adventurer
who retired after losing his party in the Underdark. Covers
pain with humor. Knows every rumor in town.
speaking_style: "Casual and warm with occasional orc idioms."
knowledge_domains: [tavern, rumors, local_history, adventuring]
secret_knowledge:
- "Knows the location of a hidden dungeon entrance under the old well."
- "Witnessed the mayor accepting a bribe from the thieves' guild."
will_discuss: [drinks, rumors, adventures, town_news]
wont_discuss: [underdark, lost_party, personal_trauma]
greeting: "Welcome, welcome! What can Grok pour for ya?"
farewell: "Safe travels, friend. Don't be a stranger!"
temperature: 0.8
max_response_tokens: 200
inventory:
capacity: 50
items:
- "@ref:item/ale_mug"
- "@ref:item/wine_bottle"
- "@ref:item/bread_loaf"
tags: [merchant, bartender, friendly]
NPC with Schedule and Autonomy¶
guard_captain_rhys:
name: "Captain Rhys"
short_desc: "A stern guard captain in polished plate armor."
long_desc: "Her jaw is set, her hand never far from her sword."
keywords: [captain, rhys, guard, captain_rhys]
npc:
behavior_type: patrol
faction_id: town_guard
wander_radius: 3
respawn_time: 600
location: "@ref:room/guard_barracks"
stats:
level: 15
strength: 16
dexterity: 14
constitution: 15
intelligence: 12
wisdom: 14
charisma: 13
health:
current: 150
maximum: 150
combat:
attack_power: 25
defense: 20
speed: 12
accuracy: 85
evasion: 10
critical_chance: 10 # int (percentage), not float
# ScheduleComponent fields — uses blocks with start_hour/end_hour
schedule:
blocks:
- start_hour: 6
end_hour: 7
activity: idle
location: "@ref:room/guard_barracks"
priority: 0.5
- start_hour: 7
end_hour: 12
activity: patrol
location: "@ref:room/village_square"
priority: 0.8
- start_hour: 12
end_hour: 13
activity: idle
location: "@ref:room/tavern"
priority: 0.3
- start_hour: 13
end_hour: 20
activity: patrol
location: "@ref:room/east_gate"
priority: 0.8
- start_hour: 20
end_hour: 6
activity: idle
location: "@ref:room/guard_barracks"
priority: 0.2
schedule_adherence: 0.9
# NeedsComponent — mapping of NeedCategory → Need objects
# Valid NeedCategory values: survival, economic, purpose, comfort
needs:
needs:
survival:
category: survival
value: 0.3
decay_rate: 0.01
comfort:
category: comfort
value: 0.5
decay_rate: 0.02
economic:
category: economic
value: 0.2
decay_rate: 0.005
purpose:
category: purpose
value: 0.9
decay_rate: 0.001
mood: 0.7
stress: 0.2
# GoalsComponent — active_goals, completed_goals, failed_goals
# Valid GoalCategory values: acquire, craft, social, protect, explore, revenge, ambition, duty
goals:
active_goals:
- category: duty
description: "Protect the village zone"
priority: 0.9
target: "village"
- category: protect
description: "Investigate reported crimes"
priority: 1.0
source: event
# Memory hints for the memory system
memory_hints:
- type: semantic
content: "Has served as guard captain for 12 years."
- type: semantic
content: "Distrusts the thieves' guild but lacks proof."
- type: semantic
content: "Lost her brother in the goblin raids last winter."
tags: [guard, captain, patrol, protected]
6.4 Item Schema (maid:item:v1)¶
Note: These examples use the author-friendly format. Top-level
name,short_desc,long_desc, andkeywordsrequire the Assembly Layer (§5). Until then, wrap them incomponents: { DescriptionComponent: { ... } }. Theitem:,weapon:,armor:,consumable:, andcontainer:nested objects are similarly author-friendly shorthand for their respective components.
Weapon¶
# data/items/weapons.yaml
_meta:
schema: maid:item:v1
items:
iron_longsword:
name: "Iron Longsword"
short_desc: "A well-balanced iron longsword."
long_desc: |
This longsword has seen many battles. The blade is nicked
but sharp, and the leather grip is worn to a comfortable fit.
keywords: [sword, longsword, iron, weapon]
item:
item_type: weapon
weight: 4.5
value: 50
max_stack: 1
wear_slots: [main_hand]
# WeaponComponent (from classic-rpg pack — requires classic-rpg dependency)
weapon:
damage_dice: "2d6"
damage_type: physical
speed: 1.0
is_two_handed: false
weapon_type: sword
crit_chance: 0.05
crit_multiplier: 2.0
tags: [weapon, sword, iron, equippable]
Armor¶
chainmail_armor:
name: "Chainmail Armor"
short_desc: "A suit of interlocking iron rings."
long_desc: "Heavy but protective chainmail covering torso and arms."
keywords: [chainmail, armor, mail, iron]
item:
item_type: armor
weight: 15.0
value: 150
max_stack: 1
wear_slots: [torso]
armor:
armor_value: 12
armor_type: medium
evasion_bonus: -2
requirements:
strength: 12
level: 5
tags: [armor, medium_armor, iron, equippable]
Consumable¶
healing_potion:
name: "Healing Potion"
short_desc: "A vial of shimmering red liquid."
long_desc: "The glass vial glows faintly, warm to the touch."
keywords: [potion, healing, red, vial]
item:
item_type: consumable
weight: 0.3
value: 25
max_stack: 10
consumable:
effect_type: heal
potency: 30
duration: 0 # instant
cooldown: 5.0
use_message: "You drink the potion and feel warmth spread through your body."
tags: [consumable, potion, healing]
Container¶
treasure_chest:
name: "Wooden Treasure Chest"
short_desc: "A sturdy wooden chest with iron bindings."
long_desc: "The chest is locked with a rusty padlock."
keywords: [chest, treasure, wooden, box]
item:
item_type: container
weight: 10.0
value: 5
container:
capacity: 20
weight_limit: 50.0
locked: true
key: "@ref:item/rusty_key"
lock_difficulty: 10
contents:
- "@ref:item/gold_coins"
- "@ref:item/silver_ring"
tags: [container, locked, furniture]
Quest Item¶
ancient_medallion:
name: "Ancient Medallion"
short_desc: "A tarnished medallion inscribed with forgotten runes."
long_desc: |
The medallion is heavy for its size. The runes around its edge
shift and shimmer when you tilt it in the light, as if alive.
keywords: [medallion, ancient, runes, quest]
item:
item_type: quest_item
weight: 0.2
value: 0 # cannot be sold
max_stack: 1
quest_item:
quest_id: "@ref:quest/investigate_ruins"
objective_id: find_medallion
unique: true
destroyable: false
description_hint: "This seems important. Perhaps someone in the village knows about it."
tags: [quest_item, unique, no_drop, no_sell]
6.5 Monster / Spawn Schema (maid:monster:v1) — Pack-Provided¶
Pack-provided schemas (§6.5–§6.10): The following entity types are not built into the engine. They are registered by content packs (e.g.,
maid-classic-rpg) viacustom_entity_type_configs(). These examples show the intended schema when the registering pack is loaded.These examples also use the author-friendly format (top-level
name,short_desc, etc.) which requires the Assembly Layer (§5). Until implemented, convert to component-centric format as shown in §6.2.
# data/monsters/goblins.yaml
_meta:
schema: maid:monster:v1
monsters:
goblin_warrior:
name: "Goblin Warrior"
short_desc: "A snarling goblin brandishing a crude spear."
long_desc: |
This goblin is larger than its scouts, wearing mismatched
leather armor stitched from various hides. It clutches a
spear with a fire-hardened tip.
keywords: [goblin, warrior, goblin_warrior]
npc:
behavior_type: aggressive
faction_id: goblin_tribe
respawn_time: 300
wander_radius: 2
stats:
level: 4
strength: 12
dexterity: 13
constitution: 10
intelligence: 6
wisdom: 7
charisma: 4
health:
current: 35
maximum: 35
regeneration_rate: 0.5
combat:
attack_power: 12
defense: 8
speed: 10
accuracy: 70
evasion: 15
critical_chance: 5 # int (percentage)
# Behavior configuration
behavior:
aggro_radius: 3
flee_threshold: 0.2 # flees at 20% health
assist_allies: true
assist_radius: 2
patrol_path: []
abilities:
- name: "spear_thrust"
cooldown: 8
chance: 0.3
- name: "war_cry"
cooldown: 30
chance: 0.1
# Loot table
loot:
gold:
min: 5
max: 15
experience: 120
drops:
- item: "@ref:item/crude_spear"
chance: 0.25
- item: "@ref:item/goblin_ear"
chance: 0.8
- item: "@ref:item/leather_scraps"
chance: 0.4
- item: "@ref:item/healing_herb"
chance: 0.05
# Ecology
ecology:
species: goblin
diet: omnivore
habitat: [cave, forest_edge, ruins]
activity_cycle: nocturnal
social_group: tribe
territory_radius: 5
predators: [wolf, bear, adventurer]
prey: [rabbit, chicken, traveler]
tags: [monster, goblin, aggressive, hostile]
# Spawn configuration for groups
goblin_patrol_spawn:
_template: true
spawn:
template: "@ref:monster/goblin_warrior"
count:
min: 2
max: 4
area: "@ref:zone/goblin_territory"
rooms:
- "@ref:room/forest_edge"
- "@ref:room/goblin_outpost"
- "@ref:room/ruined_watchtower"
respawn_interval: 600
max_active: 8
time_restriction: night # only spawns at night
6.6 Quest Schema (maid:quest:v1) — Pack-Provided¶
# data/quests/investigate_ruins.yaml
_meta:
schema: maid:quest:v1
quests:
investigate_ruins:
name: "The Forgotten Ruins"
description: |
Elder Miriam has asked you to investigate strange lights
seen near the old ruins north of the village.
short_description: "Investigate the strange lights at the old ruins."
quest_giver: "@ref:npc/elder_miriam"
level_range:
min: 3
max: 8
category: main_story
repeatable: false
prerequisites:
- type: level
value: 3
- type: quest_complete
quest: "@ref:quest/welcome_to_village"
- type: reputation
faction: village
min: 10
objectives:
- id: travel_to_ruins
type: reach_location
description: "Travel to the old ruins."
target: "@ref:room/ruin_entrance"
order: 1
- id: find_medallion
type: collect_item
description: "Search the ruins and find the source of the lights."
target: "@ref:item/ancient_medallion"
count: 1
order: 2
- id: defeat_guardians
type: kill
description: "Defeat the spectral guardians protecting the medallion."
target_tag: spectral_guardian
count: 3
order: 2 # same order = can be done in parallel with find_medallion
optional: false
- id: return_to_miriam
type: talk_to
description: "Return the medallion to Elder Miriam."
target: "@ref:npc/elder_miriam"
required_item: "@ref:item/ancient_medallion"
order: 3
# Branching: player can choose to keep or return the medallion
branches:
- id: return_medallion
condition: "has_item(ancient_medallion) AND talk_to(elder_miriam)"
description: "Return the medallion to Elder Miriam."
next_quest: "@ref:quest/the_elder_secret"
- id: keep_medallion
condition: "has_item(ancient_medallion) AND NOT talk_to(elder_miriam, 3d)"
description: "Keep the medallion for yourself."
next_quest: "@ref:quest/medallion_corruption"
reputation_change:
village: -20
rewards:
experience: 500
gold: 100
items:
- "@ref:item/explorers_boots"
reputation:
village: 15
unlock:
- "@ref:quest/the_elder_secret"
failure:
conditions:
- type: timer
duration: "7d"
message: "Too much time has passed. The ruins have collapsed."
consequences:
reputation:
village: -5
dialogue:
offer: |
Strange lights have been seen near the old ruins to the north.
No one has dared investigate. Will you go?
accept: "Be careful out there. Take this torch — you'll need it."
decline: "I understand. Perhaps another brave soul will step forward."
in_progress: "Have you found anything at the ruins?"
complete: "The medallion! I haven't seen one of these in decades..."
failed: "The ruins have collapsed. Whatever secrets they held are lost."
tags: [main_story, exploration, combat, level_3]
6.7 Zone Schema (maid:zone:v1) — Pack-Provided¶
# data/zones.yaml
_meta:
schema: maid:zone:v1
zones:
village:
name: "Millbrook Village"
description: "A peaceful farming village nestled in a river valley."
level_range:
min: 1
max: 5
pvp_enabled: false
respawn_room: "@ref:room/village_square"
ambient_music: village_peaceful
tags: [safe_zone, starter_area, town]
goblin_territory:
name: "Goblin Territory"
description: "The dark forests claimed by the local goblin tribe."
level_range:
min: 3
max: 8
pvp_enabled: false
respawn_room: "@ref:room/forest_path"
ambient_music: forest_danger
danger_level: moderate
tags: [wilderness, hostile, goblin]
ancient_ruins:
name: "The Forgotten Ruins"
description: "Crumbling remnants of a civilization lost to time."
level_range:
min: 5
max: 12
pvp_enabled: false
respawn_room: "@ref:room/ruin_entrance"
danger_level: high
instanced: false
tags: [dungeon, undead, ruins]
6.8 Spell Schema (maid:spell:v1) — Pack-Provided¶
# data/spells/fire.yaml
_meta:
schema: maid:spell:v1
spells:
fireball:
name: "Fireball"
description: "Hurls an explosive ball of fire at the target."
school: evocation
element: fire
level: 3
mana_cost: 25
casting:
time: 1.5 # seconds
range: 5 # rooms
area: 2 # radius
verbal: true
somatic: true
effects:
- type: damage
damage_type: fire
base: 30
scaling:
stat: intelligence
ratio: 0.8
- type: status
status: burning
duration: 10
chance: 0.3
tick_damage: 5
requirements:
level: 5
intelligence: 14
prerequisite_spell: "@ref:spell/fire_bolt"
cooldown: 8
tags: [offensive, aoe, fire]
minor_heal:
name: "Minor Heal"
description: "Channels restorative energy to mend wounds."
school: restoration
element: holy
level: 1
mana_cost: 10
casting:
time: 1.0
range: 1
area: 0
verbal: true
somatic: true
effects:
- type: heal
base: 20
scaling:
stat: wisdom
ratio: 1.0
requirements:
level: 1
wisdom: 10
cooldown: 4
tags: [healing, single_target, holy]
6.9 Skill Schema (maid:skill:v1) — Pack-Provided¶
# data/skills/crafting_skills.yaml
_meta:
schema: maid:skill:v1
skills:
blacksmithing:
name: "Blacksmithing"
description: "The art of forging weapons and armor from metal."
category: crafting
max_level: 100
stat_modifiers:
strength: 0.3
dexterity: 0.2
level_thresholds:
- level: 10
title: "Apprentice Smith"
unlocks: ["basic_weapons", "basic_repairs"]
- level: 25
title: "Journeyman Smith"
unlocks: ["iron_weapons", "iron_armor"]
- level: 50
title: "Expert Smith"
unlocks: ["steel_weapons", "steel_armor", "enchanted_repairs"]
- level: 75
title: "Master Smith"
unlocks: ["masterwork_weapons", "rare_materials"]
- level: 100
title: "Legendary Smith"
unlocks: ["legendary_weapons", "legendary_armor"]
experience_sources:
- action: craft_item
base_xp: 10
scaling: item_level
- action: repair_item
base_xp: 5
- action: study
base_xp: 2
requires: "@ref:room/smithy"
tags: [crafting, production]
6.10 Shop Schema (maid:shop:v1) — Pack-Provided¶
# data/shops.yaml
_meta:
schema: maid:shop:v1
shops:
village_general_store:
name: "Millbrook General Store"
description: "A well-stocked shop selling everyday adventuring supplies."
shopkeeper: "@ref:npc/merchant_tomas"
location: "@ref:room/market_lane"
buy_multiplier: 0.5 # buys items at 50% of value
sell_multiplier: 1.2 # sells at 120% of value
inventory:
- item: "@ref:item/healing_potion"
stock: 10
restock_interval: 3600 # 1 hour
max_stock: 10
- item: "@ref:item/torch"
stock: 20
restock_interval: 1800
max_stock: 20
price_override: 5
- item: "@ref:item/rope"
stock: 5
restock_interval: 7200
max_stock: 5
- item: "@ref:item/iron_longsword"
stock: 2
restock_interval: 14400
max_stock: 2
requirements:
reputation:
merchants_guild: 0 # no rep needed for basic shop
tags: [shop, general, village]
6.11 Template Inheritance Examples¶
Templates use _use and _extends to reduce duplication:
# data/templates/base_creatures.yaml
_meta:
schema: maid:template:v1
templates:
base_goblin:
_template: true
npc:
behavior_type: aggressive
faction_id: goblin_tribe
respawn_time: 300
stats:
strength: 10
dexterity: 12
constitution: 8
intelligence: 6
wisdom: 7
charisma: 4
combat:
speed: 10
accuracy: 65
evasion: 15
behavior:
aggro_radius: 3
assist_allies: true
assist_radius: 2
loot:
gold:
min: 3
max: 10
drops:
- item: "@ref:item/goblin_ear"
chance: 0.8
base_undead:
_template: true
npc:
behavior_type: aggressive
faction_id: undead
respawn_time: 600
tags: [undead, hostile]
behavior:
aggro_radius: 2
flee_threshold: 0 # undead never flee
assist_allies: true
Using templates in entity definitions:
# data/monsters/goblin_variants.yaml
_meta:
schema: maid:monster:v1
monsters:
goblin_scout:
_use: base_goblin
name: "Goblin Scout"
short_desc: "A wiry goblin with a shortbow."
long_desc: "Small and fast, this goblin prefers to attack from hiding."
keywords: [goblin, scout]
stats:
level: 2
dexterity: 14 # override: scouts are nimble
health:
current: 20
maximum: 20
combat:
attack_power: 8
defense: 4
loot:
experience: 60
_append:
drops:
- item: "@ref:item/crude_shortbow"
chance: 0.2
goblin_shaman:
_use: base_goblin
name: "Goblin Shaman"
short_desc: "A goblin adorned with bone fetishes and war paint."
long_desc: "Its eyes glow with unnatural power."
keywords: [goblin, shaman, caster]
_vars:
mana_pool: 40
stats:
level: 5
intelligence: 12 # override: shamans are smart
wisdom: 11
health:
current: 25
maximum: 25
mana:
current: "${mana_pool}"
maximum: "${mana_pool}"
combat:
attack_power: 6
defense: 5
behavior:
abilities:
- name: "shadow_bolt"
cooldown: 6
chance: 0.5
- name: "heal_ally"
cooldown: 15
chance: 0.3
loot:
experience: 180
_append:
drops:
- item: "@ref:item/bone_fetish"
chance: 0.15
- item: "@ref:item/mana_crystal"
chance: 0.05
tags: [caster, magic_user]
6.12 Custom Schema Registration¶
Content packs can register their own entity types and schemas. For example,
the classic RPG pack might register maid:spell:v1:
# In ClassicRPGContentPack.on_before_pipeline() or via register_component_types()
from maid_engine.loader.entity_types import EntityTypeConfig
SPELL_CONFIG = EntityTypeConfig(
type_name="spell",
required_components=frozenset(), # spells use custom data, not ECS components
default_components={},
default_tags=frozenset({"spell"}),
allowed_top_level_fields=frozenset({
"school", "element", "level", "mana_cost",
"casting", "effects", "requirements", "cooldown",
}),
)
# Register with the pipeline's entity type registry
context.entity_type_registry.register("spell", SPELL_CONFIG)
6.13 Schema Evolution / Migration Strategy¶
Schema versions follow these rules:
-
Minor additions (new optional fields) do NOT require a version bump. The pipeline ignores unknown fields by default.
-
Breaking changes (renamed fields, removed fields, semantic changes) require a new version:
maid:room:v2. -
Migration adapters convert between versions:
class RoomV1ToV2Adapter:
"""Migrate room schema from v1 to v2."""
def applies_to(self, schema: str) -> bool:
return schema == "maid:room:v1"
def migrate(self, data: dict[str, Any]) -> dict[str, Any]:
# v2 renames 'long_desc' to 'description'
if "long_desc" in data:
data["description"] = data.pop("long_desc")
data.setdefault("_meta", {})["schema"] = "maid:room:v2"
return data
-
Migration adapters are registered with the pipeline and run during the PreparePhase, before component validation.
-
Deprecation warnings are emitted for old schema versions:
WARNING MAID-M001 data/rooms.yaml:1
Schema 'maid:room:v1' is deprecated. Migrate to 'maid:room:v2'.
→ Run 'maid data migrate --schema maid:room:v2 data/rooms.yaml'
7. JSON Schema Export & IDE Integration¶
7.1 Overview¶
MAID components are Pydantic-based (or dataclass-based with known field types). We can generate JSON Schema from these definitions automatically, giving world builders IntelliSense, auto-complete, and inline validation in VS Code, JetBrains IDEs, and any editor supporting the YAML Language Server.
7.2 Schema Generation Architecture¶
Component classes EntityTypeConfig
(Pydantic/dataclass) (required/default components)
│ │
▼ ▼
┌─────────────────────────────────────────┐
│ SchemaGenerator │
│ - introspect component fields │
│ - map Python types to JSON Schema │
│ - merge entity type config defaults │
│ - add @ref: pattern for UUID fields │
│ - add enum values for known types │
│ - generate $defs for shared components │
└─────────────────────────────────────────┘
│
▼
JSON Schema files
schemas/
room.schema.json (engine-provided)
npc.schema.json (engine-provided)
item.schema.json (engine-provided)
template.schema.json (engine-provided)
+ any pack-registered types
7.3 CLI Command: maid data schema export¶
⚠️ PROPOSED — This command does not exist today. Currently available schema commands are
maid data schema listandmaid data schema show.
# Export all registered schemas (engine-provided + any loaded pack schemas)
$ maid data schema export --format jsonschema --output schemas/
Exported 4 schemas to schemas/
schemas/room.schema.json
schemas/npc.schema.json
schemas/item.schema.json
schemas/template.schema.json
# With classic-rpg loaded, additional pack-provided schemas are included:
$ maid data schema export --format jsonschema --output schemas/ --include-packs
Exported 10 schemas to schemas/
schemas/room.schema.json (engine)
schemas/npc.schema.json (engine)
schemas/item.schema.json (engine)
schemas/template.schema.json (engine)
schemas/monster.schema.json (classic-rpg)
schemas/quest.schema.json (classic-rpg)
schemas/zone.schema.json (classic-rpg)
schemas/spell.schema.json (classic-rpg)
schemas/skill.schema.json (classic-rpg)
schemas/shop.schema.json (classic-rpg)
# Export a single schema
$ maid data schema export room --format jsonschema
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "maid:room:v1",
"title": "MAID Room Definition",
"description": "Schema for room entities in MAID content packs.",
"type": "object",
"properties": {
"_meta": {
"type": "object",
"properties": {
"schema": {
"type": "string",
"const": "maid:room:v1"
}
}
},
"rooms": {
"type": "object",
"additionalProperties": {
"$ref": "#/$defs/RoomDefinition"
}
}
},
"$defs": {
"RoomDefinition": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "description": "Display name" },
"short_desc": { "type": "string" },
"long_desc": { "type": "string" },
"keywords": {
"type": "array",
"items": { "type": "string" }
},
"exits": {
"type": "object",
"propertyNames": {
"enum": ["north","south","east","west","up","down",
"northeast","northwest","southeast","southwest"]
},
"additionalProperties": {
"oneOf": [
{ "type": "string", "pattern": "^@ref:" },
{ "$ref": "#/$defs/DoorExit" }
]
}
},
"zone": { "type": "string" },
"extended": { "$ref": "#/$defs/ExtendedRoom" },
"tags": {
"type": "array",
"items": { "type": "string" }
}
}
},
"DoorExit": {
"type": "object",
"required": ["target"],
"properties": {
"target": { "type": "string", "pattern": "^@ref:" },
"door": {
"type": "object",
"properties": {
"name": { "type": "string" },
"locked": { "type": "boolean", "default": false },
"key": { "type": "string", "pattern": "^@ref:" },
"difficulty": { "type": "integer", "minimum": 0 },
"description": { "type": "string" }
}
}
}
},
"ExtendedRoom": {
"type": "object",
"properties": {
"descriptions": {
"type": "object",
"properties": {
"time_variants": {
"type": "object",
"propertyNames": {
"enum": ["dawn", "morning", "noon", "afternoon",
"dusk", "evening", "night", "midnight"]
}
},
"season_variants": {
"type": "object",
"propertyNames": {
"enum": ["spring", "summer", "autumn", "winter"]
}
},
"weather_effects": { "type": "object" },
"mood": { "type": "string" },
"atmosphere_text": { "type": "string" },
"random_details": {
"type": "array",
"items": { "$ref": "#/$defs/RoomDetail" }
},
"conditional_details": {
"type": "array",
"items": { "$ref": "#/$defs/RoomDetail" }
}
}
}
}
},
"RoomDetail": {
"type": "object",
"required": ["text"],
"properties": {
"text": { "type": "string" },
"weight": { "type": "number", "default": 1.0, "minimum": 0 },
"conditions": { "type": "object" }
}
}
}
}
# Show existing schema info (enhanced)
$ maid data schema show room
Schema: maid:room:v1
Entity Type: room
Required Components: DescriptionComponent
Default Components: ExtendedRoomComponent
Default Tags: room
Allowed Top-Level Fields: exits, zone
Components:
DescriptionComponent:
name: str (required)
short_desc: str = ""
long_desc: str = ""
keywords: list[str] = []
ExtendedRoomComponent:
descriptions: ExtendedDescriptions
time_variants: dict[TimeOfDay, str] = {}
season_variants: dict[Season, str] = {}
weather_effects: dict[Weather, str] = {}
mood: str = ""
atmosphere_text: str = ""
random_details: list[RoomDetail] = []
conditional_details: list[RoomDetail] = []
Example:
rooms:
my_room:
name: "My Room"
short_desc: "A simple room."
exits:
north: "@ref:room/other_room"
7.4 VS Code Integration¶
Auto-Generated .vscode/settings.json¶
$ maid data schema setup-ide --editor vscode
Created schemas/ directory with 10 schema files.
Updated .vscode/settings.json with YAML schema associations.
Generated .vscode/settings.json:
{
"yaml.schemas": {
"./schemas/room.schema.json": ["data/rooms.yaml", "data/rooms/**/*.yaml"],
"./schemas/npc.schema.json": ["data/npcs.yaml", "data/npcs/**/*.yaml"],
"./schemas/item.schema.json": [
"data/items.yaml",
"data/items/**/*.yaml"
],
"./schemas/monster.schema.json": [
"data/monsters.yaml",
"data/monsters/**/*.yaml"
],
"./schemas/quest.schema.json": [
"data/quests.yaml",
"data/quests/**/*.yaml"
],
"./schemas/zone.schema.json": ["data/zones.yaml"],
"./schemas/spell.schema.json": [
"data/spells.yaml",
"data/spells/**/*.yaml"
],
"./schemas/skill.schema.json": [
"data/skills.yaml",
"data/skills/**/*.yaml"
],
"./schemas/shop.schema.json": ["data/shops.yaml", "data/shops/**/*.yaml"],
"./schemas/template.schema.json": [
"data/templates.yaml",
"data/templates/**/*.yaml"
]
},
"yaml.customTags": ["!include scalar", "!env scalar"]
}
What This Enables¶
- Auto-complete for all field names in YAML files
- Type validation — red squiggles for wrong types (string where int expected)
- Enum suggestions — exit directions, damage types, spell schools
- Required field warnings — missing
nameon a room @ref:pattern matching — validates reference format- Hover documentation — shows field descriptions from component docstrings
7.5 IntelliSense for @ref: Targets¶
⚠️ PROPOSED — The
maid data schema export-refscommand does not exist today. It would be implemented alongsidemaid data schema export.
For cross-entity reference completion, we generate a supplementary schema file that contains all known entity IDs:
$ maid data schema export-refs --output schemas/refs.json
Scanned 3 data paths, found 247 referenceable entities.
Wrote schemas/refs.json
This file is regenerated on each maid data load or proposed maid data watch cycle,
keeping the IDE in sync with the current world state.
8. Enhanced Validation Rules Library¶
8.1 Current State¶
The pipeline ships with only 2 semantic rules:
| ID | Description | Severity |
|---|---|---|
MAID-S001 |
Health current ≤ maximum | ERROR |
MAID-S005 |
Room should have at least one exit | WARNING |
This is insufficient for catching common authoring mistakes.
8.2 Proposed Rule Library (29 rules)¶
Rules are organized into five categories: Structural, Referential, Balance, Completeness, and Consistency.
Structural Rules¶
| ID | Description | Severity | Applies To | Example Catch |
|---|---|---|---|---|
MAID-S001 |
Health current ≤ maximum | ERROR | npc, monster | health: {current: 150, maximum: 100} |
MAID-S002 |
Mana current ≤ maximum | ERROR | npc, monster | mana: {current: 80, maximum: 50} |
MAID-S003 |
Item weight must be non-negative | ERROR | item | item: {weight: -5.0} |
MAID-S004 |
Inventory capacity must be positive | ERROR | npc, item | inventory: {capacity: 0} |
MAID-S005 |
Room should have at least one exit | WARNING | room | A room with no exits field |
MAID-S006 |
NPC must have a location | ERROR | npc | NPC missing location field |
MAID-S007 |
Container contents within capacity | WARNING | item | Container with 30 items but capacity 20 |
MAID-S008 |
Spell mana cost must be positive | ERROR | spell | mana_cost: 0 |
MAID-S009 |
Skill max_level must be ≥ 1 | ERROR | skill | max_level: 0 |
MAID-S010 |
Quest must have at least one objective | ERROR | quest | Quest with empty objectives list |
Referential Rules¶
| ID | Description | Severity | Applies To | Example Catch |
|---|---|---|---|---|
MAID-R010 |
Exit targets must resolve to rooms | ERROR | room | exits: {north: "@ref:room/nonexistent"} |
MAID-R011 |
NPC spawn room must exist | ERROR | npc | location: "@ref:room/deleted_room" |
MAID-R012 |
Quest objectives reference valid entities | ERROR | quest | Quest objective targets a non-existent NPC |
MAID-R013 |
Shop items must exist | ERROR | shop | Shop sells "@ref:item/removed_item" |
MAID-R014 |
Spell prerequisites must exist | WARNING | spell | prerequisite_spell: "@ref:spell/typo_name" |
MAID-R015 |
Loot table items must exist | ERROR | monster | Loot references "@ref:item/nonexistent" |
MAID-R016 |
Key items must exist for locked doors | ERROR | room | Door references key: "@ref:item/lost_key" |
Balance Rules¶
| ID | Description | Severity | Applies To | Example Catch |
|---|---|---|---|---|
MAID-B001 |
Monster XP should be proportional to level | WARNING | monster | Level 2 monster giving 10,000 XP |
MAID-B002 |
Weapon damage range should be reasonable for level | WARNING | item | Level 1 weapon doing 500 damage |
MAID-B003 |
Stat values should be within 1-30 range | WARNING | npc, monster | strength: 999 |
MAID-B004 |
Loot drop chances must sum to ≤ 1.0 per item | ERROR | monster | Drop chance of 1.5 |
Completeness Rules¶
| ID | Description | Severity | Applies To | Example Catch |
|---|---|---|---|---|
MAID-C001 |
Rooms should have descriptions | WARNING | room | Room with name but empty long_desc |
MAID-C002 |
NPCs should have short descriptions | WARNING | npc | NPC with no short_desc |
MAID-C003 |
Quest should have dialogue entries | WARNING | quest | Quest with no dialogue.offer text |
MAID-C004 |
Items should have keywords | WARNING | item | Item with empty keywords list |
Consistency Rules¶
| ID | Description | Severity | Applies To | Example Catch |
|---|---|---|---|---|
MAID-K001 |
Bidirectional exits should match | WARNING | room | Room A has north→B, but B has no south→A |
MAID-K002 |
Zone membership should be consistent | WARNING | room, npc | NPC in room with zone "forest" but NPC tagged "village" |
MAID-K003 |
Quest prerequisite chains should not be circular | ERROR | quest | Quest A requires B, B requires A |
MAID-K004 |
NPC faction should be a known faction | WARNING | npc | faction_id: "unknown_faction" |
8.3 Rule Implementation Pattern¶
All rules follow the SemanticRule protocol. Here is an example for the
bidirectional exit rule:
@dataclass(slots=True, frozen=True)
class BidirectionalExitRule:
"""Checks that room exits have matching return exits.
If room A has a north exit to room B, room B should have a
south exit back to room A. Missing return exits are flagged
as warnings since one-way exits are sometimes intentional.
"""
id: str = "MAID-K001"
description: str = "Bidirectional exits should have matching return paths"
severity: ErrorSeverity = ErrorSeverity.WARNING
applies_to: set[str] | None = field(
default_factory=lambda: {"room"}
)
OPPOSITE_DIRECTIONS: ClassVar[dict[str, str]] = {
"north": "south",
"south": "north",
"east": "west",
"west": "east",
"up": "down",
"down": "up",
"northeast": "southwest",
"southwest": "northeast",
"northwest": "southeast",
"southeast": "northwest",
}
def check(
self,
entity: EntityDefinition,
context: LoaderContext,
) -> list[LoadError]:
errors: list[LoadError] = []
exits = entity.raw_data.get("exits", {})
if not isinstance(exits, dict):
return errors
for direction, target in exits.items():
# Resolve target to entity definition
target_id = self._resolve_target(target)
if not target_id:
continue # referential rules handle missing targets
opposite = self.OPPOSITE_DIRECTIONS.get(direction)
if not opposite:
continue # non-standard direction, skip
# Look up target in the context's entity definitions list
target_entity = next(
(e for e in context.entity_definitions if e.id == target_id),
None,
)
if not target_entity:
continue
target_exits = target_entity.raw_data.get("exits", {})
if opposite not in target_exits:
errors.append(
LoadError(
code=self.id,
file_path=str(entity.source_file) if entity.source_file else "",
line=entity.line,
field_path=f"exits.{direction}",
message=(
f"Room '{entity.id}' has {direction} exit to "
f"'{target_id}', but '{target_id}' has no "
f"{opposite} exit back. Is this intentional?"
),
severity=self.severity,
suggestion=(
f"Add '{opposite}: \"@ref:room/{entity.id}\"' "
f"to {target_id}'s exits, or tag this room with "
f"'one_way_exit' to suppress this warning."
),
)
)
return errors
def _resolve_target(self, target: Any) -> str | None:
if isinstance(target, str) and target.startswith("@ref:"):
parts = target.split("/", 1)
return parts[1] if len(parts) > 1 else None
if isinstance(target, dict):
return self._resolve_target(target.get("target"))
return None
8.4 Rule Configuration¶
Current capability: The loader supports a global LoaderConfig.skip_rules set.
Rules whose IDs are in this set are skipped during validation. This is wired through
LoaderConfig (and will be accessible via the proposed DataDrivenContentPack, see §4.2).
# Example: skipping existing built-in rules
config = LoaderConfig(
strict_mode=True,
skip_rules={"MAID-S001", "MAID-S005"}, # skip specific rules globally
)
Or via CLI:
Note on rule IDs: Only
MAID-S001(HealthBoundsRule) andMAID-S005(RoomExitRule) exist today as built-in rules. All other rule IDs shown in §8.5 (MAID-S002 through MAID-K004) are proposed and will be implemented as part of the enhanced validation library (Phase 2, §14).Future Work: The following features are not yet implemented and are deferred to a later phase:
- Per-file rule configuration in
_meta.yaml(e.g.,rules: {MAID-S005: {enabled: false}})- Per-entity
_suppressdirective (e.g.,_suppress: [MAID-S005])- Severity overrides (e.g., promoting a WARNING to ERROR per-pack)
- Rule parameter customization (e.g.,
max_stat_value: 50)These require changes to
PreparePhaseto read per-file and per-entity configuration and thread it through to rule execution.
8.5 CLI Rule Listing¶
$ maid data validate --list-rules
Existing Built-in Rules (2):
MAID-S001 ERROR Health current ≤ maximum [npc, monster]
MAID-S005 WARNING Room should have at least one exit [room]
Proposed Structural Rules (8):
MAID-S002 ERROR Mana current ≤ maximum [npc, monster]
MAID-S003 ERROR Item weight must be non-negative [item]
MAID-S004 ERROR Inventory capacity must be positive [npc, item]
MAID-S006 ERROR NPC must have a location [npc]
MAID-S007 WARNING Container contents within capacity [item]
MAID-S008 ERROR Spell mana cost must be positive [spell]
MAID-S009 ERROR Skill max_level must be ≥ 1 [skill]
MAID-S010 ERROR Quest must have at least one objective [quest]
Proposed Referential Rules (7):
MAID-R010 ERROR Exit targets must resolve to rooms [room]
MAID-R011 ERROR NPC spawn room must exist [npc]
MAID-R012 ERROR Quest objectives reference valid entities [quest]
MAID-R013 ERROR Shop items must exist [shop]
MAID-R014 WARNING Spell prerequisites must exist [spell]
MAID-R015 ERROR Loot table items must exist [monster]
MAID-R016 ERROR Key items must exist for locked doors [room]
Proposed Balance Rules (4):
MAID-B001 WARNING Monster XP proportional to level [monster]
MAID-B002 WARNING Weapon damage reasonable for level [item]
MAID-B003 WARNING Stat values within 1-30 range [npc, monster]
MAID-B004 ERROR Loot drop chances ≤ 1.0 [monster]
Proposed Completeness Rules (4):
MAID-C001 WARNING Rooms should have descriptions [room]
MAID-C002 WARNING NPCs should have short descriptions [npc]
MAID-C003 WARNING Quest should have dialogue entries [quest]
MAID-C004 WARNING Items should have keywords [item]
Proposed Consistency Rules (4):
MAID-K001 WARNING Bidirectional exits should match [room]
MAID-K002 WARNING Zone membership consistent [room, npc]
MAID-K003 ERROR Quest prerequisite chains acyclic [quest]
MAID-K004 WARNING NPC faction is a known faction [npc]
Total: 2 existing + 27 proposed = 29 rules (13 ERROR, 16 WARNING)
9. Enhanced Quickstart & Scaffolding¶
9.1 Updated maid quickstart new¶
The existing quickstart generates imperative Python code that creates entities inline.
The updated version will generate projects using the proposed DataDrivenContentPack
base class (§4) with YAML content as the primary authoring surface.
$ maid quickstart new my-dungeon
? Choose a template:
❯ minimal — A single area with a few rooms (best for learning)
standard — Village hub with NPCs, items, and a quest
full — Complete world with zones, combat, economy, and quests
? Include AI dialogue examples? (y/N): y
? Include example quest? (Y/n): Y
Creating content pack 'my-dungeon'...
✓ my-dungeon/manifest.toml
✓ my-dungeon/data/rooms.yaml
✓ my-dungeon/data/npcs.yaml
✓ my-dungeon/data/items.yaml
✓ my-dungeon/data/quests/welcome.yaml
✓ my-dungeon/README.md
✓ my-dungeon/.vscode/settings.json
✓ my-dungeon/schemas/room.schema.json
✓ my-dungeon/schemas/npc.schema.json
✓ my-dungeon/schemas/item.schema.json
Pack created! Next steps:
cd my-dungeon
maid data validate data/ # Check for errors
maid data preview data/ # See what will be loaded
maid server start # Start the server
9.2 Template: Minimal¶
Note: The generated YAML examples below use the author-friendly format (top-level
name,exits, etc.) which requires the Assembly Layer (§5). Until implemented, the scaffolder would generate the component-centric format instead (see §6.2 for the pattern).
Generated manifest.toml:
[pack]
name = "my-dungeon"
version = "0.1.0"
description = "A new MAID content pack"
dependencies = { stdlib = ">=1.0.0" }
provides = ["my-dungeon"]
requires = ["ecs", "event-bus"]
authors = []
Generated data/rooms.yaml:
# my-dungeon rooms
# Docs: https://maid.readthedocs.io/en/latest/authoring/rooms/
_meta:
schema: maid:room:v1
rooms:
entrance:
name: "Dungeon Entrance"
short_desc: "A crumbling stone archway leads into darkness."
long_desc: |
Cold air seeps from the dark passage beyond the archway.
Moss covers the ancient stonework, and faded runes are
barely visible along the lintel.
keywords: [entrance, archway, dungeon]
exits:
north: "@ref:room/first_chamber"
zone: dungeon
first_chamber:
name: "First Chamber"
short_desc: "A small stone chamber with passages in three directions."
long_desc: |
This chamber was once a guard room. A broken table and
overturned chairs suggest a hasty departure long ago.
Passages lead deeper into the dungeon.
keywords: [chamber, guard, room]
exits:
south: "@ref:room/entrance"
north: "@ref:room/corridor"
east: "@ref:room/storage_room"
zone: dungeon
corridor:
name: "Dark Corridor"
short_desc: "A long, narrow corridor stretching into shadows."
long_desc: |
The corridor is barely wide enough for two people to walk
abreast. Water drips from the ceiling, and your footsteps
echo off the stone walls.
keywords: [corridor, hall, passage]
exits:
south: "@ref:room/first_chamber"
zone: dungeon
storage_room:
name: "Storage Room"
short_desc: "A dusty room filled with rotting crates."
long_desc: |
Wooden crates line the walls, most of them crumbling with
age. A few look like they might still hold something useful.
keywords: [storage, crates, supplies]
exits:
west: "@ref:room/first_chamber"
zone: dungeon
No pack.py is generated for the minimal template. Once the proposed
zero-Python pack support is implemented (§4.5), the ContentPackLoader
will discover the pack from manifest.toml alone and auto-create a
DataDrivenContentPack instance.
Until §4.5 is implemented: The minimal template will include a thin
pack.pythat extendsBaseContentPackand invokes the pipeline manually, or authors can usemaid data loadto load content independently of pack discovery.
9.3 Template: Standard¶
The standard template adds pack.py with a DataDrivenContentPack subclass
(proposed, see §4), custom commands, NPCs with dialogue, items, and a fetch quest:
Generated src/my_dungeon/pack.py:
"""My Dungeon content pack."""
from maid_engine.commands.registry import CommandRegistry, LayeredCommandRegistry
from maid_engine.plugins.data_driven import DataDrivenContentPack
from maid_engine.plugins.manifest import ContentPackManifest
from .commands import register_commands
class MyDungeonContentPack(DataDrivenContentPack):
"""My Dungeon — a content pack for MAID."""
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="my-dungeon",
version="0.1.0",
description="A new MAID content pack",
dependencies={"stdlib": ">=1.0.0"},
)
def get_dependencies(self) -> list[str]:
return ["stdlib"]
def register_commands(
self, registry: CommandRegistry | LayeredCommandRegistry
) -> None:
register_commands(registry)
Generated data/npcs.yaml:
_meta:
schema: maid:npc:v1
npcs:
old_hermit:
name: "The Old Hermit"
short_desc: "A disheveled old man muttering to himself."
long_desc: |
The hermit's wild eyes dart around nervously. His threadbare
robes were once fine, suggesting a fall from grace.
keywords: [hermit, old_man, npc]
location: "@ref:room/first_chamber"
npc:
behavior_type: stationary
dialogue_id: hermit_dialogue
is_quest_giver: true
dialogue:
ai_enabled: true
personality: |
A paranoid old wizard who lost his spellbook in the dungeon.
He is desperate for help but too afraid to go deeper himself.
Speaks in half-finished sentences and nervous whispers.
speaking_style: "Nervous, whispering, trails off mid-sentence..."
knowledge_domains: [dungeon, magic, old_kingdom]
greeting: "Psst! You there! Don't... don't go further without hearing me out."
farewell: "Be careful... the shadows have eyes down here..."
temperature: 0.7
health:
current: 30
maximum: 30
tags: [quest_giver, friendly, npc]
Generated data/items.yaml:
_meta:
schema: maid:item:v1
items:
rusty_key:
name: "Rusty Key"
short_desc: "An old iron key covered in orange rust."
long_desc: "Despite the rust, the key's teeth look intact."
keywords: [key, rusty, iron]
item:
item_type: quest_item
weight: 0.1
value: 0
tags: [quest_item, key]
dusty_tome:
name: "Dusty Tome"
short_desc: "A leather-bound book covered in dust."
long_desc: |
The book's cover bears a faded arcane symbol. Its pages
are yellowed but legible.
keywords: [tome, book, dusty, spellbook]
item:
item_type: quest_item
weight: 1.0
value: 50
tags: [quest_item, book, magic]
torch:
name: "Wooden Torch"
short_desc: "A simple wooden torch wrapped in oil-soaked cloth."
long_desc: "It provides a warm, flickering light."
keywords: [torch, light, wooden]
item:
item_type: equipment
weight: 0.5
value: 2
wear_slots: [off_hand]
tags: [light_source, equipment]
Generated data/quests/welcome.yaml:
_meta:
schema: maid:quest:v1
quests:
find_spellbook:
name: "The Lost Spellbook"
description: |
The Old Hermit lost his spellbook somewhere deeper in the dungeon.
Find it and return it to him.
short_description: "Find the hermit's lost spellbook."
quest_giver: "@ref:npc/old_hermit"
level_range:
min: 1
max: 3
category: side_quest
repeatable: false
objectives:
- id: find_tome
type: collect_item
description: "Find the Dusty Tome in the storage room."
target: "@ref:item/dusty_tome"
count: 1
order: 1
- id: return_tome
type: talk_to
description: "Return the tome to the Old Hermit."
target: "@ref:npc/old_hermit"
required_item: "@ref:item/dusty_tome"
order: 2
rewards:
experience: 100
gold: 25
items:
- "@ref:item/rusty_key"
dialogue:
offer: |
My spellbook! I dropped it when I fled from the
shadows. It should be in the storage room to the east.
Please, bring it back to me!
accept: "Oh thank you, thank you! Be careful in there..."
in_progress: "Did you find it? The storage room, to the east..."
complete: |
My book! You found it! Here, take this key — it opens
a passage I discovered before I lost my nerve.
tags: [starter, fetch_quest]
9.4 Template: Full¶
The full template includes everything from standard plus:
- Multiple zones (
data/zones.yaml) - Monster templates with loot tables (
data/monsters/) - Shop configuration (
data/shops.yaml) - Spell and skill definitions (
data/spells/,data/skills/) - Template inheritance examples (
data/templates/) - Custom systems directory with a skeleton combat override
- A multi-stage quest chain
_meta.yamlwith load ordering
9.5 Interactive Wizard Flow¶
$ maid quickstart new --interactive
? Project name: haunted-forest
? Display name: The Haunted Forest
? Description: A spooky forest filled with undead creatures
? Author name: World Builder
? Starting template: standard
? Include AI dialogue? Yes
? Include example quest? Yes
? Include combat monsters? Yes
? Set up VS Code integration? Yes
? Initialize git repository? Yes
Creating 'haunted-forest'...
[==============================] 100%
Done! Your content pack is ready.
cd haunted-forest
maid data validate data/
maid server start
Read the guide: https://maid.readthedocs.io/en/latest/guides/first-mud/
10. Tutorial World Migration Plan¶
10.1 Migration Goal¶
Convert the tutorial world from ~9,200 lines of imperative Python to a
DataDrivenContentPack (proposed, §4) with YAML content and minimal Python.
This migrated pack will become the reference implementation for the
YAML-first authoring workflow once the design is implemented.
10.2 What Moves to YAML¶
| Content Type | Current Location | Lines | Moves to YAML? |
|---|---|---|---|
| Room definitions | areas/village.py, areas/forest.py, areas/goblin_camp.py, areas/hidden_cave.py |
~3,200 | ✅ Yes |
| Room creation logic | areas/__init__.py, areas/common.py |
~400 | ✅ Yes (replaced by pipeline) |
| NPC definitions | npcs/shopkeeper.py, npcs/goblin.py, npcs/boss.py, npcs/quest_giver.py |
~2,800 | ✅ Yes |
| Item definitions | items/weapons.py, items/potions.py, items/quest_items.py |
~1,200 | ✅ Yes |
| Quest definitions | quests/ |
~600 | ✅ Yes |
| Pack orchestration | pack.py (on_load) |
~450 | ✅ Most (replaced by pipeline) |
| Systems | systems/ |
~300 | ❌ Stays in Python |
| Commands | commands/ |
~250 | ❌ Stays in Python |
| Events | events/ |
~100 | ❌ Stays in Python |
| Existing YAML data | data/rooms.yaml, data/npcs.yaml, data/items.yaml |
~100 | ✅ Already YAML (expand) |
Summary: ~8,200 lines of Python → ~1,500 lines of YAML + ~650 lines of Python.
10.3 Before / After: pack.py¶
Before (731 lines, abbreviated):¶
class TutorialWorldContentPack(BaseContentPack):
"""Tutorial content pack — imperative content creation."""
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(name="tutorial-world", version="1.0.0") # ...
def get_dependencies(self) -> list[str]:
return ["stdlib"]
def get_systems(self, world: World) -> list[System]:
return [HintSystem(world), ProgressTracker(world)]
def get_events(self) -> list[type[Event]]:
return [TutorialProgressEvent, HintTriggeredEvent]
def register_commands(self, registry) -> None:
register_tutorial_commands(registry)
async def on_load(self, engine: GameEngine) -> None:
world = engine.world
# Check for existing entities (persistence)
if await engine.has_pack_entities("tutorial-world"):
await self._rehydrate(engine)
return
# Create rooms (delegates to area modules)
await engine.clear_pack_entities("tutorial-world")
village_rooms = await load_village_area(world)
forest_rooms = await load_forest_area(world)
goblin_rooms = await load_goblin_camp_area(world)
cave_rooms = await load_hidden_cave_area(world)
# Spawn NPCs
elder = await spawn_elder_miriam(world, village_rooms["square"])
shopkeeper = await spawn_shopkeeper(world, village_rooms["market"])
# ... 15 more NPC spawns ...
# Spawn items
await spawn_weapons(world, village_rooms)
await spawn_potions(world, village_rooms)
await spawn_quest_items(world, cave_rooms)
# Register quests
await register_tutorial_quests(world, elder, ...)
# Track entities for persistence
await engine.track_pack_entities("tutorial-world", all_entities)
After (~80 lines):¶
class TutorialWorldContentPack(DataDrivenContentPack):
"""Tutorial content pack — YAML-first with Python systems."""
@property
def manifest(self) -> ContentPackManifest:
return ContentPackManifest(
name="tutorial-world",
version="2.0.0",
description="Tutorial and example content for learning MAID",
dependencies={"stdlib": ">=1.0.0"},
)
def get_dependencies(self) -> list[str]:
return ["stdlib"]
def get_systems(self, world: World) -> list[System]:
return [HintSystem(world), ProgressTracker(world)]
def get_events(self) -> list[type[Event]]:
return [TutorialProgressEvent, HintTriggeredEvent]
def register_commands(
self, registry: CommandRegistry | LayeredCommandRegistry
) -> None:
register_tutorial_commands(registry)
async def on_after_pipeline(
self, engine: GameEngine, result: PipelineResult
) -> None:
"""Post-load wiring that requires runtime references."""
world = engine.world
# The pipeline has created all rooms, NPCs, items, and quests.
# We only need to wire up quest registration and tutorial hints.
# Note: World does not have get_entity_by_name(). We search by
# iterating entities and checking DescriptionComponent.
elder = next(
(e for e in world.get_all_entities()
if e.has(DescriptionComponent)
and e.get(DescriptionComponent).name == "Elder Miriam"),
None,
)
if elder:
await register_tutorial_hints(world, elder)
logger.info(
"Tutorial world loaded: %d entities from YAML",
result.entity_count,
)
10.4 Migrated Data Directory Structure¶
packages/maid-tutorial-world/
src/maid_tutorial_world/
pack.py ← 80 lines (was 731)
systems/
hints.py ← unchanged
progress.py ← unchanged
commands/
tutorial_commands.py ← unchanged
events/
tutorial_events.py ← unchanged
data/
_meta.yaml ← load order
zones.yaml ← zone definitions
rooms/
village.yaml ← village rooms
forest.yaml ← forest rooms
goblin_camp.yaml ← goblin camp rooms
hidden_cave.yaml ← hidden cave rooms
npcs/
village_npcs.yaml ← Elder Miriam, shopkeeper, etc.
forest_npcs.yaml ← forest creatures
goblin_npcs.yaml ← goblin warriors, shaman
cave_npcs.yaml ← cave boss, minions
items/
weapons.yaml ← swords, bows, staffs
potions.yaml ← healing, mana potions
quest_items.yaml ← medallion, keys, etc.
quests/
welcome.yaml ← starter quest
fetch_bread.yaml ← fetch quest example
goblin_threat.yaml ← combat quest example
templates/
base_creatures.yaml ← shared NPC templates
10.5 Migration Steps¶
- Create
_meta.yaml— Define load order: templates → zones → rooms → items → npcs → quests - Extract room definitions — Convert
RoomDefinitiondataclasses inareas/*.pyto YAML - Extract NPC definitions — Convert factory functions in
npcs/*.pyto YAML - Extract item definitions — Convert
create_weapon(),create_potion()calls to YAML - Create quest YAML — Formalize the quest definitions already partially in YAML
- Create templates — Extract common patterns (base_goblin, base_villager) into templates
- Update
pack.py— Switch fromBaseContentPacktoDataDrivenContentPack - Delete factory modules — Remove
areas/,npcs/,items/Python modules - Test — Run
maid data validatethen full test suite - Update tutorial docs — Reference YAML files instead of Python modules
10.6 Persistence Compatibility¶
Current state: The
InstantiatePhasedoes not perform reconciliation with existing entities. It always creates new staged entities viaLoadTransactionwithresolve_load_action(EntityLoadState.NEW, ...). It never inspects existing entities, never readsDataProvenanceComponent, and does not comparedefinition_hashvalues.
Prerequisite work (tracked in §14.1):
Before the tutorial migration can support persistence correctly, the
InstantiatePhase must be extended with reconciliation logic:
- Lookup — Before creating a staged entity, check if an entity with the same UUID already exists in the world.
- Hash comparison — Compare the existing entity's
DataProvenanceComponent.definition_hashagainst the new definition's hash. - Skip if unchanged — If hashes match, skip creation (entity is current).
- Update if changed — If hashes differ and the entity has not been modified
at runtime (
InstanceStateComponent.modified == False), replace it. - Conflict if dirty — If the YAML changed AND the entity was modified at runtime, flag as a conflict (warn, skip, or force based on configuration).
- Stale instance handling — Define quarantine behavior for entities that exist in the world but are no longer in the YAML definitions.
Until this reconciliation is implemented, DataDrivenContentPack will
unconditionally recreate all entities on each load. The tutorial migration
should initially handle this by clearing pack entities before loading (the
existing engine.clear_pack_entities() pattern), with reconciliation added
as a fast-follow.
11. "Building Your First MUD" Guide Outline¶
11.1 Target Audience¶
Complete beginners to MUD development. Assumes: - Basic text editor skills - Familiarity with YAML syntax (or willing to learn) - No Python experience required for chapters 1-5 - Basic Python for chapters 6+
11.2 Chapter Outline¶
Chapter 1: Your First Room (15 min)¶
- What is a MUD? What is MAID?
- Install MAID (
pip install maid-engine) maid quickstart new my-world --template minimal- Examine the generated
data/rooms.yaml maid data validate data/maid server start- Connect with telnet and look around
- Exercise: Add a second room with an exit
Chapter 2: Building a Zone (30 min)¶
- Rooms as the building blocks of a world
- Exit directions and bidirectional exits
- The
@ref:room/namesyntax - Zone metadata
- Extended room descriptions (time, weather)
maid data preview data/to see what loads- Exercise: Create a 5-room village with a zone
Chapter 3: Populating with NPCs (30 min)¶
- NPC basics: name, description, location
- NPC components: behavior, faction, dialogue
- Simple dialogue with greetings and farewells
- NPC schedules (time-based movement)
maid data validate data/for NPC validation- Exercise: Add a shopkeeper and a guard to the village
Chapter 4: Items and Equipment (30 min)¶
- Item types: weapon, armor, consumable, container, quest_item
- Equip slots and requirements
- Stackable items
- Containers and locked doors
- Shops and merchants
- Exercise: Create a weapon shop with 3 items
Chapter 5: Your First Quest (45 min)¶
- Quest structure: objectives, rewards, dialogue
- Objective types: reach_location, collect_item, kill, talk_to
- Quest prerequisites
- Branching quests
- Testing quests in-game
- Exercise: Create a multi-objective fetch quest
Chapter 6: Templates and Reuse (30 min)¶
- The
_usedirective - Template inheritance with
_extends - Variable substitution with
_vars - List appending with
_append - Organizing templates in
data/templates/ - Exercise: Create goblin variants from a base template
Chapter 7: Adding Behavior with Python (45 min)¶
- When YAML isn't enough
- Creating a
pack.pywithDataDrivenContentPack - Writing your first custom command
- Writing a simple system (tick-based)
- Hybrid packs: YAML content + Python behavior
- Exercise: Add a
searchcommand that finds hidden items
Chapter 8: AI-Powered NPCs (30 min)¶
- Enabling AI dialogue on an NPC
- Personality and speaking style
- Knowledge domains and secrets
- Will discuss / won't discuss boundaries
- Testing with
maid dev test-ai - Exercise: Create an AI bartender who knows local rumors
Chapter 9: Combat and Monsters (45 min)¶
- Requires
maid-classic-rpgdependency - Monster definitions with behavior and loot
- Spawn configurations
- Ecology: habitats, activity cycles, food chains
- Boss encounters
- Exercise: Create a goblin camp with 3 monster types
Chapter 10: Publishing Your World (30 min)¶
- Validating your pack:
maid validate my-world/ - Writing a
manifest.toml - Publishing to the MAID registry
- Versioning and dependencies
- Documentation best practices
- Exercise: Publish your world to a local registry
12. Mix-and-Match Architecture¶
12.1 Overview¶
MAID supports four content pack architectures along a spectrum from pure data to pure code. All four are first-class citizens and can coexist in the same server.
Pure YAML ←————————————————————————→ Pure Python
(zero Python) (hybrid) (hybrid) (code only)
│ │ │ │
▼ ▼ ▼ ▼
manifest.toml DataDriven DataDriven BaseContentPack
+ data/ + data/ + data/ + on_load()
+ pack.py + pack.py
+ systems/
+ commands/
12.2 Pure YAML Packs (Zero Python)¶
Use case: Static content areas, lore packs, item databases.
Structure:
elven-forest/
manifest.toml
data/
zones.yaml
rooms/
canopy.yaml
grove.yaml
stream.yaml
npcs/
elves.yaml
forest_creatures.yaml
items/
elven_weapons.yaml
herbs.yaml
How it works:
ContentPackLoader._load_from_directory()findsmanifest.tomlbut nopack.py.- It creates a
DataDrivenContentPackinstance, injecting the manifest and data path. - The engine loads it like any other pack.
- Dependencies declared in
manifest.tomlare resolved normally:
Limitations:
- No custom systems (relies on stdlib or dependency systems)
- No custom commands
- No custom events
- No custom pipeline phases or validation rules
- No post-load wiring beyond what the pipeline provides
When to use: You just want to add rooms, NPCs, and items to an existing world. Think of it as a "map pack" or "content DLC."
12.3 Pure Python Packs (Unchanged from Today)¶
Use case: Systems-only packs, existing packs, packs where content is generated procedurally.
Structure:
weather-system/
src/weather_system/
pack.py ← extends BaseContentPack
systems/
weather.py
sky_renderer.py
events/
weather_events.py
commands/
weather_commands.py
How it works:
Exactly as today. The pack extends BaseContentPack and creates all content
(if any) in on_load(). The YAML pipeline is not involved.
When to use: Pure behavior packs (no static content), procedural generation, legacy packs you don't want to migrate.
12.4 Hybrid Packs (YAML Content + Python Behavior)¶
This is the recommended architecture for most content packs once
DataDrivenContentPack is implemented (§4).
Structure:
haunted-manor/
src/haunted_manor/
pack.py ← extends DataDrivenContentPack (proposed)
systems/
haunting.py
ghost_spawner.py
commands/
seance.py
events/
haunting_events.py
data/
rooms/
ground_floor.yaml
upper_floor.yaml
basement.yaml
npcs/
ghosts.yaml
servants.yaml
items/
cursed_items.yaml
keys.yaml
quests/
investigate_manor.yaml
templates/
base_ghost.yaml
How it works (once DataDrivenContentPack is implemented):
DataDrivenContentPack.on_load()will run the pipeline, creating rooms, NPCs, and items from YAML.on_after_pipeline()will wire up Python-specific logic (register event handlers, configure AI, etc.).get_systems()returns Python systems that operate on YAML-defined entities.register_commands()registers Python commands.- Python systems reference YAML entities by name or tag — not by hardcoded UUID.
Key pattern — finding YAML entities from Python:
Note: The current
WorldAPI does not provideget_entity_by_name()orget_entities_by_tag(). Entity lookup requires iteratingget_all_entities()orentities_in_room()and checking components/tags. Adding convenience query helpers toWorld(name index, tag index) is a recommended Phase 1 enhancement but is not required forDataDrivenContentPackto function.
async def on_after_pipeline(self, engine, result):
world = engine.world
# Find by name (iterate and check DescriptionComponent)
ghost_lord = next(
(e for e in world.get_all_entities()
if e.has(DescriptionComponent)
and e.get(DescriptionComponent).name == "The Ghost Lord"),
None,
)
# Find by tag (iterate and check tags)
all_ghosts = [
e for e in world.get_all_entities()
if e.has_tag("ghost")
]
# Find by component (iterate and check component presence)
haunted_rooms = [
e for e in world.get_all_entities()
if e.has(HauntedComponent)
]
12.5 Custom Loaders Alongside Pipeline¶
A pack can use the pipeline for most content while loading specific data through custom logic:
class MyPack(DataDrivenContentPack):
async def on_before_pipeline(self, engine):
"""Load custom data that the pipeline doesn't handle."""
# Load a custom loot table format
loot_path = self.data_paths[0] / "custom" / "loot_tables.json"
if loot_path.exists():
self.loot_tables = json.loads(loot_path.read_text())
async def on_after_pipeline(self, engine, result):
"""Wire custom data to pipeline-created entities."""
for entity in engine.world.get_all_entities():
if not entity.has_tag("monster"):
continue
name = entity.get(DescriptionComponent).name
if name in self.loot_tables:
entity.get(LootComponent).table = self.loot_tables[name]
12.6 Extension Points¶
Note: Extension points marked with
DataDrivenContentPackare part of the proposed design (§4) and do not exist yet.
| Extension Point | Where | Purpose |
|---|---|---|
| Custom phases | DataDrivenContentPack.custom_phases() (proposed) |
Add pipeline phases (e.g., food chain resolution) |
| Phase ordering | DataDrivenContentPack.phase_order() (proposed) |
Full control over phase sequence |
| Custom entity types | EntityTypeConfig registration |
New schema types beyond room/npc/item/template |
| Custom validation rules | DataDrivenContentPack.custom_rules() (proposed) |
Pack-specific semantic checks |
| Template directives | TemplateResolver extension |
New template merge behaviors |
| Pre-load hooks | on_before_pipeline() (proposed) |
Setup before pipeline runs |
| Post-load hooks | on_after_pipeline() (proposed) |
Wiring after entities exist |
| Error handling | on_pipeline_failure() (proposed) |
Custom fallback on load failure |
| Context extras | pipeline_context_extras() (proposed) |
Pass data to custom phases |
| Data paths | data_paths property override (proposed) |
Non-standard directory layouts |
12.7 Content Pack Dependencies (YAML referencing across packs)¶
A YAML-only pack can depend on entities from another pack using the pack:
prefix in @ref: expressions:
# In elven-forest/data/npcs.yaml (depends on stdlib items)
npcs:
elven_merchant:
name: "Elven Merchant"
location: "@ref:room/elven_market"
inventory:
items:
- "@ref:stdlib:item/healing_potion" # from stdlib pack
- "@ref:item/elven_bow" # local to this pack
The current ResolveRefsPhase supports cross-pack references using the syntax
@ref:<pack_name>:<type>/<name>. Resolution works as follows:
- If the body contains a
:and a/, it splits on the first:to getpack_prefixandtype/name, then callsregistry.lookup_by_type(type, name, pack_prefix). - If the body contains only a
/, it uses the current pack name as the prefix.
Constraints:
- Declaring the referenced pack as a dependency in
manifest.tomlis recommended to ensure correct load ordering, but the resolver does not enforce this — it simply looks up names in the sharedReferenceRegistry. If a pack's symbols are registered (because it loaded first), they are resolvable regardless of manifest declarations. - Circular cross-pack references are an error.
- Cross-pack references only work for entities, not templates.
Note: Deferred resolution (resolving references to packs that haven't loaded yet) is not currently implemented. The
ResolveRefsPhaseruns once per pack during its pipeline execution. Cross-pack references only resolve if the referenced pack has already been loaded (i.e., is earlier in the dependency-sorted load order). Deferred resolution with a second pass after all packs load is a future enhancement.
12.8 How Pack A's YAML Can @ref: Entities from Pack B¶
The reference resolution syntax (matching the current ResolveRefsPhase):
@ref:room/tavern → local pack's room named "tavern"
@ref:stdlib:item/potion → stdlib pack's item named "potion"
@ref:uuid:550e8400-... → direct UUID reference (any pack)
Resolution order:
- Local pack first —
@ref:room/tavernchecks the current pack's registry. - Explicit pack prefix —
@ref:stdlib:item/potionchecks only the named pack's registry viaregistry.lookup_by_type(type, name, pack_name).
There is no global fallback — if a reference cannot be resolved, it is reported as an unresolved reference (error in strict mode, warning in lenient mode).
13. CLI Enhancements¶
13.1 maid data diff — YAML vs. Loaded State¶
⚠️ PROPOSED — This command does not exist today. It would be implemented as part of Phase 4 (§14.4).
Compare what's defined in YAML files against what's currently loaded in the engine. Useful for detecting drift when YAML changes haven't been reloaded.
$ maid data diff data/
Comparing data/ against loaded engine state...
Added (in YAML, not in engine):
+ room/secret_passage data/rooms/hidden_cave.yaml:45
+ npc/mysterious_stranger data/npcs/cave_npcs.yaml:12
Modified (YAML differs from loaded):
~ room/village_square data/rooms/village.yaml:8
- long_desc: "The heart of the village..."
+ long_desc: "The bustling heart of the village..."
~ npc/elder_miriam data/npcs/village_npcs.yaml:3
- health.maximum: 80
+ health.maximum: 100
Removed (in engine, not in YAML):
- item/deprecated_sword (no source file)
Summary: 2 added, 2 modified, 1 removed
Run 'maid data reload data/' to apply changes.
Implementation: Run the pipeline in preview mode (no instantiation),
then compare EntityDefinition hashes against DataProvenanceComponent.definition_hash
on loaded entities.
13.2 maid data watch — Auto-Reload on File Changes¶
⚠️ PROPOSED — This command does not exist today. It would be implemented as part of Phase 4 (§14.4).
Watch the data directory for changes and automatically re-validate or reload.
# Watch with validation only (safe, no side effects)
$ maid data watch data/ --validate-only
Watching data/ for changes...
[14:32:05] data/rooms/village.yaml changed → validating...
[14:32:05] ✓ Valid (4 rooms, 0 errors, 0 warnings)
[14:32:18] data/npcs/village_npcs.yaml changed → validating...
[14:32:18] ✗ 1 error:
MAID-S006 data/npcs/village_npcs.yaml:25
NPC 'new_guard' must have a location.
→ Add 'location: "@ref:room/guard_post"'
# Watch with live reload (requires running server)
$ maid data watch data/ --reload
Watching data/ for changes (live reload enabled)...
[14:33:01] data/items/weapons.yaml changed → reloading...
[14:33:01] ✓ Reloaded: 3 items updated, 1 item added
Implementation: Uses watchfiles (already a dependency for hot reload) to monitor the
filesystem. On change, runs the pipeline in validate mode. If --reload is
specified, performs a full pipeline run and swaps entities atomically.
13.3 maid data export — Export Loaded Entities to YAML¶
⚠️ PROPOSED — This command does not exist today. It would be implemented as part of Phase 4 (§14.4).
Export the current in-engine entity state back to YAML files. Useful for: - Backing up in-game builder changes to source control - Migrating imperative packs to YAML (the tutorial migration helper) - Inspecting the pipeline's output
# Export all entities from a pack
$ maid data export --pack tutorial-world --output export/
Exported 47 entities to export/
export/rooms/village.yaml (12 rooms)
export/rooms/forest.yaml (8 rooms)
export/rooms/goblin_camp.yaml (6 rooms)
export/rooms/hidden_cave.yaml (4 rooms)
export/npcs/village_npcs.yaml (5 npcs)
export/npcs/forest_npcs.yaml (4 npcs)
export/npcs/goblin_npcs.yaml (6 npcs)
export/items/all_items.yaml (15 items)
# Export a specific entity type
$ maid data export --type room --output rooms.yaml
Exported 30 rooms to rooms.yaml
# Export with template extraction (finds common patterns)
$ maid data export --pack tutorial-world --output export/ --extract-templates
Exported 47 entities with 4 extracted templates:
export/templates/base_goblin.yaml
export/templates/base_villager.yaml
export/templates/base_weapon.yaml
export/templates/base_potion.yaml
Implementation: Iterate over entities with DataProvenanceComponent,
group by source pack and type, serialize component data back to YAML using
the schema definitions. Template extraction uses heuristic similarity
detection on component fields.
13.4 maid data init — Scaffold Data Directory¶
Initialize a data/ directory structure for an existing content pack that
wants to migrate to YAML.
$ maid data init --pack my-pack
Initializing data directory for 'my-pack'...
✓ data/_meta.yaml
✓ data/rooms/ (empty — add room YAML files here)
✓ data/npcs/ (empty — add NPC YAML files here)
✓ data/items/ (empty — add item YAML files here)
✓ data/templates/ (empty — add shared templates here)
Ready! Start adding YAML files, then run:
maid data validate data/
# With export of existing entities
$ maid data init --pack tutorial-world --from-engine
Initializing data directory for 'tutorial-world' from loaded entities...
✓ data/_meta.yaml
✓ data/rooms/village.yaml (12 rooms exported)
✓ data/rooms/forest.yaml (8 rooms exported)
✓ data/npcs/village_npcs.yaml (5 NPCs exported)
✓ data/items/weapons.yaml (8 items exported)
...
Exported 47 entities. Review and edit the YAML files, then update
your pack.py to extend DataDrivenContentPack.
13.5 Enhanced maid data schema show¶
The existing maid data schema show is extended with examples and field
documentation:
$ maid data schema show npc --verbose
Schema: maid:npc:v1
Entity Type: npc
Required Components: DescriptionComponent, NPCComponent
Default Tags: npc
──── DescriptionComponent ────────────────────────────────
name str REQUIRED Display name of the entity
short_desc str "" One-line description shown in room
long_desc str "" Full description shown on 'look'
keywords list[str] [] Words that match this entity
──── NPCComponent ────────────────────────────────────────
template_id UUID|None None Template this NPC was created from
behavior_type str "passive" AI behavior: passive, aggressive, patrol, ...
dialogue_id str|None None ID for dialogue tree
spawn_point_id UUID|None None Room where this NPC respawns
respawn_time float 300.0 Seconds between respawn (0 = never)
wander_radius int 0 Max rooms to wander from spawn
faction_id str|None None Faction membership
is_merchant bool False Can this NPC trade?
is_quest_giver bool False Can this NPC give quests?
──── Optional Components ─────────────────────────────────
stats StatsComponent Level, attributes
health HealthComponent HP, regeneration
combat CombatComponent Attack, defense, speed
mana ManaComponent Mana pool
inventory InventoryComponent Carried items
dialogue DialogueComponent AI dialogue config
equipment EquipmentComponent Worn equipment
──── Example ─────────────────────────────────────────────
npcs:
guard:
name: "Town Guard"
short_desc: "A vigilant guard in chainmail."
long_desc: "The guard watches the road with a steady gaze."
keywords: [guard, soldier]
location: "@ref:room/town_gate"
npc:
behavior_type: patrol
faction_id: town_guard
wander_radius: 2
stats:
level: 5
strength: 14
health:
current: 60
maximum: 60
13.6 CLI Command Summary¶
| Command | Description | Status |
|---|---|---|
maid data validate |
Validate YAML content | ✅ Exists (enhance with new rules) |
maid data lint |
Lint YAML syntax | ✅ Exists |
maid data preview |
Preview entities without loading | ✅ Exists |
maid data load |
Load content into engine | ✅ Exists |
maid data resolve |
Show resolved entity | ✅ Exists |
maid data reload |
Reload content | ✅ Exists |
maid data unload |
Unload content | ✅ Exists |
maid data schema list |
List schemas | ✅ Exists (enhance) |
maid data schema show |
Show schema details | ✅ Exists (enhance with schema guide) |
maid data schema export |
Export JSON Schema | 🆕 Proposed |
maid data schema setup-ide |
Configure IDE integration | 🆕 Proposed |
maid data schema export-refs |
Export reference index | 🆕 Proposed |
maid data diff |
Compare YAML vs loaded state | 🆕 Proposed |
maid data watch |
Auto-validate/reload on changes | 🆕 Proposed |
maid data export |
Export entities to YAML | 🆕 Proposed |
maid data init |
Scaffold data directory | 🆕 Proposed |
maid data migrate |
Migrate schema versions | 🆕 Proposed |
14. Implementation Plan¶
14.1 Phase 1: Core Infrastructure (Weeks 1-3)¶
Goal: DataDrivenContentPack works end-to-end with existing pipeline.
| Task | Package | Est. | Depends On |
|---|---|---|---|
Implement DataDrivenContentPack class |
maid-engine | 3d | — |
Zero-Python pack support in ContentPackLoader |
maid-engine | 2d | DataDrivenContentPack |
| Register monster, quest, zone, spell, skill, shop entity types | maid-engine | 2d | — |
| Schema version parsing and validation | maid-engine | 1d | — |
| Unit tests for DataDrivenContentPack | maid-engine | 2d | DataDrivenContentPack |
| Integration test: minimal YAML pack loads correctly | maid-engine | 1d | All above |
| Integration test: hybrid pack with systems | maid-engine | 1d | All above |
Exit criteria: A test pack with manifest.toml + data/rooms.yaml (no Python)
loads successfully and creates entities in the world.
14.2 Phase 2: Validation & Schema (Weeks 3-5)¶
Goal: 25+ validation rules, JSON Schema export, IDE integration.
| Task | Package | Est. | Depends On |
|---|---|---|---|
| Implement 10 structural rules (MAID-S001 through S010) | maid-engine | 3d | — |
| Implement 7 referential rules (MAID-R010 through R016) | maid-engine | 2d | — |
| Implement 4 balance rules (MAID-B001 through B004) | maid-engine | 1d | — |
| Implement 4 completeness rules (MAID-C001 through C004) | maid-engine | 1d | — |
| Implement 4 consistency rules (MAID-K001 through K004) | maid-engine | 2d | — |
Rule configuration via _meta.yaml and _suppress |
maid-engine | 2d | Rules |
| JSON Schema generator from component models | maid-engine | 3d | Entity type configs |
maid data schema export command |
maid-engine | 1d | Schema generator |
maid data schema setup-ide command |
maid-engine | 1d | Schema export |
Enhanced maid data schema show with verbose output |
maid-engine | 1d | — |
| Tests for all new rules | maid-engine | 3d | Rules |
Exit criteria: maid data validate --list-rules shows 29 rules. maid data
schema export generates valid JSON Schema. VS Code shows autocomplete for
YAML entity files.
14.3 Phase 3: Quickstart & Tutorial Migration (Weeks 5-8)¶
Goal: Updated scaffolder, migrated tutorial as reference implementation.
| Task | Package | Est. | Depends On |
|---|---|---|---|
| Update quickstart: minimal template (zero Python) | maid-engine | 2d | Phase 1 |
| Update quickstart: standard template | maid-engine | 2d | Phase 1 |
| Update quickstart: full template | maid-engine | 3d | Phase 2 |
Interactive wizard mode (--interactive) |
maid-engine | 2d | Templates |
| Extract tutorial rooms to YAML | maid-tutorial-world | 2d | Phase 1 |
| Extract tutorial NPCs to YAML | maid-tutorial-world | 2d | Phase 1 |
| Extract tutorial items to YAML | maid-tutorial-world | 1d | Phase 1 |
| Create tutorial quest YAML | maid-tutorial-world | 1d | Phase 1 |
| Create shared templates | maid-tutorial-world | 1d | Phase 1 |
| Rewrite pack.py as DataDrivenContentPack | maid-tutorial-world | 1d | All extractions |
| Delete deprecated Python factory modules | maid-tutorial-world | 1d | New pack.py |
| Full regression test | maid-tutorial-world | 2d | All above |
| Update tutorial world docs | docs | 2d | Migration complete |
Exit criteria: Tutorial world loads entirely from YAML. pack.py is <100
lines. All existing tests pass. Tutorial serves as the reference implementation.
14.4 Phase 4: CLI & Guide (Weeks 8-10)¶
Goal: Full CLI toolkit and "Building Your First MUD" guide.
| Task | Package | Est. | Depends On |
|---|---|---|---|
maid data diff command |
maid-engine | 3d | Phase 1 |
maid data watch command |
maid-engine | 3d | Phase 1 |
maid data export command |
maid-engine | 3d | Phase 1 |
maid data init command |
maid-engine | 1d | Phase 1 |
maid data migrate command (schema versions) |
maid-engine | 2d | Phase 2 |
Cross-pack @ref: resolution |
maid-engine | 3d | Phase 1 |
| Write chapters 1-5 of guide (YAML only) | docs | 5d | Phase 3 |
| Write chapters 6-10 of guide (with Python) | docs | 5d | Phase 3 |
| Review and polish guide | docs | 2d | Guide chapters |
Exit criteria: All CLI commands functional. Guide published to docs site. A new contributor can follow the guide to create and publish a content pack without reading engine source code.
14.5 Dependency Graph¶
Phase 1 ──────────────────────────────────────┐
DataDrivenContentPack │
Zero-Python pack support │
Entity type registration │
│ │
├──→ Phase 2 ────────────────────┐ │
│ Validation rules │ │
│ JSON Schema export │ │
│ IDE integration │ │
│ │ │ │
│ ▼ │ │
├──→ Phase 3 ◄──────────────────┘ │
│ Quickstart update │
│ Tutorial migration │
│ │ │
│ ▼ │
└──→ Phase 4 ◄────────────────────────┘
CLI enhancements
Cross-pack refs
Guide writing
14.6 Risk Mitigation¶
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Pipeline can't handle all tutorial patterns | Medium | High | Identify gaps early with proposed maid data export prototype; extend pipeline as needed |
| JSON Schema generation incomplete | Low | Medium | Start with core components; add pack-specific schemas incrementally |
| Cross-pack reference resolution complexity | Medium | Medium | Implement simple local-first resolution; defer cross-pack to Phase 4 |
| Backward compatibility regression | Low | High | Extensive integration tests; keep BaseContentPack unchanged |
| Community adoption resistance | Medium | Low | Strong documentation; migration tooling; zero-Python on-ramp |
15. Appendices¶
Appendix A: Complete YAML Schema Reference¶
A.1 Top-Level File Structure¶
Every YAML content file follows this structure:
# Optional schema and metadata declaration (inferred from top-level key / directory if omitted)
_meta:
schema: maid:<type>:v1
author: "Builder Name" # optional
description: "Description of file" # optional
tags: [zone_name, village] # optional
# Entity collection (key = entity_id)
<plural_type>:
entity_id_1:
name: "..."
# ... fields ...
entity_id_2:
name: "..."
# ... fields ...
A.2 Common Fields (all entity types)¶
Note: Top-level
name,short_desc,long_desc, andkeywordsare author-friendly fields that require the Assembly Layer (§5) to map them toDescriptionComponent. Until the Assembly Layer is implemented, use the component-centric format:components: { DescriptionComponent: { name: ... } }. Template directives (_use,_extends,_vars,_template) work today.
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Display name |
short_desc |
string | No | One-line room description |
long_desc |
string | No | Full description |
keywords |
list[string] | No | Matching words |
tags |
list[string] | No | Entity tags |
_use |
string | No | Template to inherit from |
_extends |
string | No | Template to extend |
_vars |
object | No | Variable substitutions |
_template |
boolean | No | Mark as template (not instantiated) |
A.3 Room-Specific Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
exits |
object | Recommended | Map of direction → target or door object |
zone |
string | No | Zone membership |
extended |
object | No | ExtendedRoomComponent fields (author-friendly; requires Assembly Layer) |
A.4 NPC-Specific Fields¶
⚠️ PROPOSED (Assembly Layer) — The shorthand fields below (
npc,stats,health,mana,combat,inventory,dialogue,schedule,needs,goals,memory_hints) are author-friendly top-level fields that require the Assembly Layer (§5) to be normalized intocomponents:entries. Until the Assembly Layer is implemented, use the canonical component-centric format with explicitcomponents:mapping instead.
| Field | Type | Required | Description |
|---|---|---|---|
location |
@ref string | Yes | Room where NPC spawns |
npc |
object | Yes | NPCComponent fields (author-friendly; requires Assembly Layer) |
stats |
object | No | StatsComponent fields (author-friendly; requires Assembly Layer) |
health |
object | No | HealthComponent fields (author-friendly; requires Assembly Layer) |
mana |
object | No | ManaComponent fields (author-friendly; requires Assembly Layer) |
combat |
object | No | CombatComponent fields (critical_chance is int, not float) (author-friendly; requires Assembly Layer) |
inventory |
object | No | InventoryComponent fields (author-friendly; requires Assembly Layer) |
dialogue |
object | No | DialogueComponent fields (author-friendly; requires Assembly Layer) |
schedule |
object | No | ScheduleComponent: blocks with start_hour, end_hour, activity, location, priority (author-friendly; requires Assembly Layer) |
needs |
object | No | NeedsComponent: needs mapping of NeedCategory → Need objects (author-friendly; requires Assembly Layer) |
goals |
object | No | GoalsComponent: active_goals, completed_goals, failed_goals (author-friendly; requires Assembly Layer) |
memory_hints |
list | No | Initial memories (author-friendly; requires Assembly Layer) |
A.5 Item-Specific Fields¶
| Field | Type | Required | Description |
|---|---|---|---|
item |
object | Yes | ItemComponent fields |
weapon |
object | No | Weapon stats |
armor |
object | No | Armor stats |
consumable |
object | No | Consumable effects |
container |
object | No | Container properties |
quest_item |
object | No | Quest item metadata |
location |
@ref string | No | Room where item spawns |
A.6 Reference Syntax¶
| Syntax | Example | Resolves To |
|---|---|---|
@ref:type/id |
@ref:room/tavern |
Local pack entity by type and ID |
@ref:pack_name:type/id |
@ref:stdlib:item/potion |
Cross-pack entity (pack:type/id) |
@ref:uuid:... |
@ref:uuid:550e8400-e29b-41d4-a716-446655440000 |
Direct UUID reference |
A.7 Template Directives¶
| Directive | Purpose | Example |
|---|---|---|
_use: template_name |
Inherit all fields from template | _use: base_goblin |
_extends: template_name |
Same as _use but for chains |
_extends: base_creature |
_vars: {key: value} |
Variable substitution | _vars: {hp: 100} then maximum: "${hp}" |
_append: {field: [values]} |
Append to list fields | _append: {tags: [boss]} |
_template: true |
Mark as template (not instantiated) | Used in template definition files |
Appendix B: DataDrivenContentPack Full Interface¶
⚠️ PROPOSED — This class does not exist yet. See §4 for the full design.
class DataDrivenContentPack(BaseContentPack):
"""Complete interface reference."""
# --- Class-level configuration (override in subclass) ---
data_dir_name: str = "data"
pipeline_timeout: float = 60.0
strict_validation: bool = True
fail_on_warnings: bool = False
# --- Properties ---
@property
def data_paths(self) -> list[Path]: ...
@property
def pipeline_result(self) -> PipelineResult | None: ...
@property
def loaded_entity_count(self) -> int: ...
# --- Required override ---
@property
def manifest(self) -> ContentPackManifest: ...
# --- Optional overrides (hooks) ---
def custom_phases(self) -> list[Phase]: ...
def phase_order(self) -> list[Phase]: ...
def custom_rules(self) -> list[SemanticRule]: ...
def custom_entity_type_configs(self) -> dict[str, EntityTypeConfig]: ...
def pipeline_context_extras(self) -> dict[str, Any]: ...
async def on_before_pipeline(self, engine: GameEngine) -> None: ...
async def on_after_pipeline(self, engine: GameEngine, result: PipelineResult) -> None: ...
async def on_pipeline_failure(self, engine: GameEngine, result: PipelineResult) -> None: ...
# --- Internal (do not override) ---
def _build_loader_context(self, engine: GameEngine) -> LoaderContext: ...
# --- Inherited from BaseContentPack (override as needed) ---
def get_dependencies(self) -> list[str]: ...
def get_systems(self, world: World) -> list[System]: ...
def get_events(self) -> list[type[Event]]: ...
def register_commands(self, registry: CommandRegistry | LayeredCommandRegistry) -> None: ...
def register_document_schemas(self, store: DocumentStore) -> None: ...
def register_component_types(self, registry: ComponentRegistry) -> None: ...
def register_api_routes(self, router: APIRouter) -> None: ...
async def on_unload(self, engine: GameEngine) -> None: ...
# --- Final (do not override) ---
async def on_load(self, engine: GameEngine) -> None: ...
Appendix C: Migration Checklist¶
Use this checklist when migrating an existing BaseContentPack to
DataDrivenContentPack:
- [ ] Audit
on_load()— Identify all entity creation calls - [ ] Run
maid data init --from-engine— Export current entities to YAML - [ ] Review exported YAML — Fix names, add descriptions, verify references
- [ ] Create
_meta.yaml— Define load order if needed - [ ] Extract templates — Identify repeated patterns, create template files
- [ ] Update
@ref:references — Replace hardcoded UUIDs with symbolic refs - [ ] Change base class —
BaseContentPack→DataDrivenContentPack - [ ] Move wiring to
on_after_pipeline()— Post-load setup only - [ ] Delete factory functions — Remove Python entity creation code
- [ ] Run
maid data validate data/— Fix all errors - [ ] Run full test suite — Verify no regressions
- [ ] Run proposed
maid data diff data/— Confirm loaded state matches YAML (once implemented) - [ ] Update documentation — Reference YAML files, not Python modules
- [ ] Tag release — New pack version with
DataDrivenContentPackbase
Appendix D: Error Code Index¶
| Code Range | Category | Phase | Status |
|---|---|---|---|
MAID-D001 – MAID-D007 |
Discovery errors | DiscoverPhase | Proposed |
MAID-Y001 – MAID-Y012 |
YAML lint warnings | ParsePhase | Proposed |
MAID-PAR001 – MAID-PAR005 |
Parse errors | ParsePhase | Proposed |
MAID-PRE001 – MAID-PRE010 |
Preparation errors | PreparePhase | Proposed |
MAID-R001 – MAID-R004 |
Reference resolution | ResolveRefsPhase | Proposed |
MAID-I001 |
Instantiation errors | InstantiatePhase | Proposed |
MAID-P001 |
Pipeline control | Pipeline | Proposed |
MAID-M001 |
Schema migration | PreparePhase | Proposed |
MAID-S001 |
Health bounds | Semantic validation | Existing |
MAID-S005 |
Room exit check | Semantic validation | Existing |
MAID-S002 – MAID-S010 |
Structural rules | Semantic validation | Proposed |
MAID-R010 – MAID-R016 |
Referential rules | Semantic validation | Proposed |
MAID-B001 – MAID-B004 |
Balance rules | Semantic validation | Proposed |
MAID-C001 – MAID-C004 |
Completeness rules | Semantic validation | Proposed |
MAID-K001 – MAID-K004 |
Consistency rules | Semantic validation | Proposed |
End of design document.