Skip to content

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

  1. Executive Summary
  2. Design Principles
  3. Content Source of Truth & Reconciliation
  4. DataDrivenContentPack — New Base Class
  5. The Assembly Layer — Author-Friendly YAML to Components
  6. Standardized YAML Schemas
  7. JSON Schema Export & IDE Integration
  8. Enhanced Validation Rules Library
  9. Enhanced Quickstart & Scaffolding
  10. Tutorial World Migration Plan
  11. "Building Your First MUD" Guide Outline
  12. Mix-and-Match Architecture
  13. CLI Enhancements
  14. Implementation Plan
  15. 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:

  1. A manifest.toml (4 lines)
  2. A data/rooms.yaml file (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 report line=1 or field_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 load or 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, exits and zone are declared as allowed_top_level_fields on EntityTypeConfig and pass through the pipeline directly — they are not Assembly Layer fields and work today alongside components:. Similarly, content packs can declare their own allowed_top_level_fields for 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:

  1. Running the pipeline in preview mode (no instantiation) to produce EntityDefinition objects with definition_hash values.
  2. Comparing against DataProvenanceComponent.definition_hash on loaded entities.
  3. Checking InstanceStateComponent.modified and modified_components for 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 Conflictmaid 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:

  1. The runtime entity is modified directly.
  2. InstanceStateComponent.modified is set to True.
  3. The change is not automatically written back to YAML.
  4. The author must explicitly run the proposed maid data export to 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 InstantiatePhase does not perform reconciliation with existing entities — it always creates new staged entities via LoadTransaction. 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 reload will replace all entities unconditionally.


4. DataDrivenContentPack — New Base Class

⚠️ PROPOSED DESIGN — NOT YET IMPLEMENTED

DataDrivenContentPack does 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 (in maid_engine.plugins.protocol) provides no-op defaults for all ContentPack protocol methods. Content packs extend it and implement on_load() with imperative Python code.

4.1 Design Goals

  • Extend BaseContentPack so 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 LoaderContext with LoaderConfig, 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 real LoaderContext with LoaderConfig, wiring strict_validationconfig.strict_mode, custom_rules()context.semantic_rules (custom only; PreparePhase already prepends BUILTIN_RULES), and custom_entity_type_configs()context.entity_type_configs. The Pipeline will receive the context via default_context so run_for_pack() does not raise.

Note on reference registration: The current PreparePhase registers symbolic refs directly into the global ReferenceRegistry before 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 from PreparePhase are 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 DataDrivenContentPack class which does not exist yet. The import path maid_engine.plugins.data_driven is 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 a pack.py file. If pack.py is missing, the loader returns None and 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:

my-simple-area/
  manifest.toml         ← required
  data/
    rooms.yaml
    npcs.yaml
    items.yaml

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. PreparePhase reads components: directly and has no field-mapping step. EntityAssembler exists in maid_engine.loader.assembler but 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

⚠️ PROPOSEDEntityTypeConfig does not currently have an assembly_rule field. Its fields are: type_name, required_components, default_components, default_tags, and allowed_top_level_fields. Adding assembly_rule requires 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>:

_meta:
  schema: maid:room:v1

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 explicit components: 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: grid is not in the core room config's allowed_top_level_fields (which only allows exits and zone). Grid positioning is provided by WorldSystemsContentPack and requires that pack to register grid as an allowed field or use the components: 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, and keywords require the Assembly Layer (§5). Until then, wrap them in components: { DescriptionComponent: { ... } }. The item:, weapon:, armor:, consumable:, and container: 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) via custom_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:

  1. Minor additions (new optional fields) do NOT require a version bump. The pipeline ignores unknown fields by default.

  2. Breaking changes (renamed fields, removed fields, semantic changes) require a new version: maid:room:v2.

  3. 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
  1. Migration adapters are registered with the pipeline and run during the PreparePhase, before component validation.

  2. 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 list and maid 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

  1. Auto-complete for all field names in YAML files
  2. Type validation — red squiggles for wrong types (string where int expected)
  3. Enum suggestions — exit directions, damage types, spell schools
  4. Required field warnings — missing name on a room
  5. @ref: pattern matching — validates reference format
  6. Hover documentation — shows field descriptions from component docstrings

7.5 IntelliSense for @ref: Targets

⚠️ PROPOSED — The maid data schema export-refs command does not exist today. It would be implemented alongside maid 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:

$ maid data validate data/ --skip-rule MAID-S001 --skip-rule MAID-S005

Note on rule IDs: Only MAID-S001 (HealthBoundsRule) and MAID-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 _suppress directive (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 PreparePhase to 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.py that extends BaseContentPack and invokes the pipeline manually, or authors can use maid data load to 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.yaml with 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

  1. Create _meta.yaml — Define load order: templates → zones → rooms → items → npcs → quests
  2. Extract room definitions — Convert RoomDefinition dataclasses in areas/*.py to YAML
  3. Extract NPC definitions — Convert factory functions in npcs/*.py to YAML
  4. Extract item definitions — Convert create_weapon(), create_potion() calls to YAML
  5. Create quest YAML — Formalize the quest definitions already partially in YAML
  6. Create templates — Extract common patterns (base_goblin, base_villager) into templates
  7. Update pack.py — Switch from BaseContentPack to DataDrivenContentPack
  8. Delete factory modules — Remove areas/, npcs/, items/ Python modules
  9. Test — Run maid data validate then full test suite
  10. Update tutorial docs — Reference YAML files instead of Python modules

10.6 Persistence Compatibility

Current state: The InstantiatePhase does not perform reconciliation with existing entities. It always creates new staged entities via LoadTransaction with resolve_load_action(EntityLoadState.NEW, ...). It never inspects existing entities, never reads DataProvenanceComponent, and does not compare definition_hash values.

Prerequisite work (tracked in §14.1):

Before the tutorial migration can support persistence correctly, the InstantiatePhase must be extended with reconciliation logic:

  1. Lookup — Before creating a staged entity, check if an entity with the same UUID already exists in the world.
  2. Hash comparison — Compare the existing entity's DataProvenanceComponent.definition_hash against the new definition's hash.
  3. Skip if unchanged — If hashes match, skip creation (entity is current).
  4. Update if changed — If hashes differ and the entity has not been modified at runtime (InstanceStateComponent.modified == False), replace it.
  5. 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).
  6. 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/name syntax
  • 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 _use directive
  • 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.py with DataDrivenContentPack
  • Writing your first custom command
  • Writing a simple system (tick-based)
  • Hybrid packs: YAML content + Python behavior
  • Exercise: Add a search command 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-rpg dependency
  • 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:

  1. ContentPackLoader._load_from_directory() finds manifest.toml but no pack.py.
  2. It creates a DataDrivenContentPack instance, injecting the manifest and data path.
  3. The engine loads it like any other pack.
  4. Dependencies declared in manifest.toml are resolved normally:
[pack]
name = "elven-forest"
version = "1.0.0"
dependencies = { stdlib = ">=1.0.0" }

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):

  1. DataDrivenContentPack.on_load() will run the pipeline, creating rooms, NPCs, and items from YAML.
  2. on_after_pipeline() will wire up Python-specific logic (register event handlers, configure AI, etc.).
  3. get_systems() returns Python systems that operate on YAML-defined entities.
  4. register_commands() registers Python commands.
  5. Python systems reference YAML entities by name or tag — not by hardcoded UUID.

Key pattern — finding YAML entities from Python:

Note: The current World API does not provide get_entity_by_name() or get_entities_by_tag(). Entity lookup requires iterating get_all_entities() or entities_in_room() and checking components/tags. Adding convenience query helpers to World (name index, tag index) is a recommended Phase 1 enhancement but is not required for DataDrivenContentPack to 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 DataDrivenContentPack are 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:

  1. If the body contains a : and a /, it splits on the first : to get pack_prefix and type/name, then calls registry.lookup_by_type(type, name, pack_prefix).
  2. 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.toml is recommended to ensure correct load ordering, but the resolver does not enforce this — it simply looks up names in the shared ReferenceRegistry. 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 ResolveRefsPhase runs 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:

  1. Local pack first@ref:room/tavern checks the current pack's registry.
  2. Explicit pack prefix@ref:stdlib:item/potion checks only the named pack's registry via registry.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, and keywords are author-friendly fields that require the Assembly Layer (§5) to map them to DescriptionComponent. 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 into components: entries. Until the Assembly Layer is implemented, use the canonical component-centric format with explicit components: 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 NeedCategoryNeed 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 classBaseContentPackDataDrivenContentPack
  • [ ] 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 DataDrivenContentPack base

Appendix D: Error Code Index

Code Range Category Phase Status
MAID-D001MAID-D007 Discovery errors DiscoverPhase Proposed
MAID-Y001MAID-Y012 YAML lint warnings ParsePhase Proposed
MAID-PAR001MAID-PAR005 Parse errors ParsePhase Proposed
MAID-PRE001MAID-PRE010 Preparation errors PreparePhase Proposed
MAID-R001MAID-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-S002MAID-S010 Structural rules Semantic validation Proposed
MAID-R010MAID-R016 Referential rules Semantic validation Proposed
MAID-B001MAID-B004 Balance rules Semantic validation Proposed
MAID-C001MAID-C004 Completeness rules Semantic validation Proposed
MAID-K001MAID-K004 Consistency rules Semantic validation Proposed

End of design document.