Skip to content

Plugin System Enhancements Design Specification

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


Executive Summary

This document specifies enhancements to MAID's plugin (content pack) system to achieve feature parity with Evennia's mature plugin ecosystem. The four major enhancement areas are:

  1. Hot Loading/Reloading - Runtime content pack modification without server restart
  2. Plugin Ecosystem Infrastructure - Tools and registry to grow from 3 to 40+ plugins
  3. Extensive Plugin Documentation - Comprehensive documentation system for plugin authors
  4. Community Contribution Guidelines - Framework for community-contributed plugins

These enhancements address the gaps identified in the MAID vs Evennia comparison where Evennia leads in plugin ecosystem maturity, hot reload capabilities, documentation, and community infrastructure.


Table of Contents

  1. Feature 1: Hot Loading/Reloading System
  2. Feature 2: Plugin Ecosystem Infrastructure
  3. Feature 3: Plugin Documentation System
  4. Feature 4: Community Contribution Guidelines
  5. Appendix A: Migration Strategies
  6. Appendix B: Risk Assessment

Feature 1: Hot Loading/Reloading System

1.1 Feature Overview

What it does:
Enables runtime loading, unloading, and reloading of content packs without requiring a full server restart. This includes safely swapping out ECS systems, components, event handlers, and commands while the game is running with connected players.

Why it's needed: - Development velocity: Iterate on game content without restarting (currently requires full restart) - Production maintenance: Deploy fixes without player disconnection - Content management: Enable/disable features dynamically based on server load or events - Parity with Evennia's dynamic typeclass reloading capability

Current Limitation:
MAID's GameEngine.load_content_pack() raises RuntimeError if called while engine is running (line 219 of engine.py: if self._state != EngineState.STOPPED).

Note: Hot reload is now fully implemented in maid_engine.reload/, which allows runtime pack loading and reloading without stopping the engine.

1.2 User Stories

US-1.1: Developer Hot Reload During Development

As a content pack developer, I want to reload my pack after making code changes so that I can test modifications without restarting the server and recreating my test state.

US-1.2: Admin Runtime Feature Toggle

As a server administrator, I want to disable a problematic content pack at runtime so that I can mitigate issues without kicking all players offline.

US-1.3: Scheduled Content Activation

As a game designer, I want to load seasonal content packs on a schedule so that holiday events activate automatically without manual intervention.

US-1.4: Safe Component Migration

As a developer, I want the hot reload system to automatically migrate entity data when component schemas change so that player data isn't lost during updates.

US-1.5: Graceful System Replacement

As a developer, I want to hot-swap a combat system implementation so that I can A/B test different combat mechanics with live players.

1.3 Technical Requirements

1.3.1 Core Hot Reload Requirements

ID Requirement Priority
HR-001 System SHALL support loading new content packs while engine is in RUNNING state P0
HR-002 System SHALL support unloading content packs while engine is in RUNNING state P0
HR-003 System SHALL support reloading (unload + load) content packs atomically P0
HR-004 System SHALL pause tick processing during hot reload operations P0
HR-005 System SHALL validate pack compatibility before attempting hot reload P0
HR-006 System SHALL rollback failed hot reload operations P0
HR-007 System SHALL emit events before and after hot reload operations P1
HR-008 System SHALL support hot reload of individual systems within a pack P1
HR-009 System SHALL log all hot reload operations for audit P1
HR-010 System SHALL support hot reload via CLI command P1
HR-011 System SHALL support hot reload via admin in-game command P2
HR-012 System SHALL support file-watch auto-reload in development mode P2

1.3.2 ECS-Specific Requirements

ID Requirement Priority
ECS-001 System SHALL safely remove systems from SystemManager during tick pause P0
ECS-002 System SHALL preserve entity data when system is replaced P0
ECS-003 System SHALL migrate component data when component schema changes P0
ECS-004 System SHALL handle orphaned components (component type removed) P0
ECS-005 System SHALL re-register systems in correct priority order P0
ECS-006 System SHALL call system.shutdown() before removal P0
ECS-007 System SHALL call system.startup() after addition P0
ECS-008 System SHALL support system enable/disable without full unload P1
ECS-009 System SHALL validate system dependencies before reload P1

1.3.3 Event System Requirements

ID Requirement Priority
EVT-001 System SHALL unsubscribe all event handlers for unloaded pack P0
EVT-002 System SHALL preserve pending events during hot reload P0
EVT-003 System SHALL re-register event handlers on pack load P0
EVT-004 System SHALL track handler ownership by pack P0
EVT-005 System SHALL support handler hot-swap (replace without losing subscriptions) P1

1.3.4 Command System Requirements

ID Requirement Priority
CMD-001 System SHALL unregister all commands for unloaded pack P0
CMD-002 System SHALL expose fallback command when higher-priority removed P0
CMD-003 System SHALL re-register commands on pack load P0
CMD-004 System SHALL notify active sessions of command changes P1

1.4 API/Interface Design

1.4.1 HotReloadManager Class

"""Hot reload management for content packs.

Handles safe loading, unloading, and reloading of content packs
while the game engine is running.
"""

from __future__ import annotations

import asyncio
import logging
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import TYPE_CHECKING, Any, AsyncIterator, Callable
from uuid import UUID

if TYPE_CHECKING:
    from maid_engine.core.engine import GameEngine
    from maid_engine.plugins.protocol import ContentPack


class HotReloadState(Enum):
    """State of a hot reload operation."""
    PENDING = auto()
    VALIDATING = auto()
    PAUSING = auto()
    UNLOADING = auto()
    LOADING = auto()
    MIGRATING = auto()
    RESUMING = auto()
    COMPLETED = auto()
    FAILED = auto()
    ROLLED_BACK = auto()


@dataclass
class HotReloadResult:
    """Result of a hot reload operation."""
    success: bool
    pack_name: str
    operation: str  # "load", "unload", "reload"
    state: HotReloadState
    duration_ms: float
    error: Exception | None = None
    warnings: list[str] = field(default_factory=list)
    migrated_entities: int = 0
    orphaned_components: int = 0


@dataclass
class ComponentMigration:
    """Describes a component schema migration."""
    component_type: str
    from_version: str
    to_version: str
    migration_fn: Callable[[dict[str, Any]], dict[str, Any]]


@dataclass
class HotReloadContext:
    """Context passed through hot reload lifecycle."""
    pack_name: str
    operation: str
    state: HotReloadState
    engine: GameEngine
    affected_entities: set[UUID] = field(default_factory=set)
    migrations: list[ComponentMigration] = field(default_factory=list)
    rollback_actions: list[Callable[[], None]] = field(default_factory=list)
    metadata: dict[str, Any] = field(default_factory=dict)


class HotReloadError(Exception):
    """Error during hot reload operation."""

    def __init__(
        self, 
        message: str, 
        context: HotReloadContext | None = None,
        cause: Exception | None = None,
    ) -> None:
        super().__init__(message)
        self.context = context
        self.cause = cause


class DependencyViolationError(HotReloadError):
    """Cannot unload pack because other packs depend on it."""

    def __init__(self, pack_name: str, dependents: list[str]) -> None:
        super().__init__(
            f"Cannot unload '{pack_name}': required by {dependents}"
        )
        self.dependents = dependents


class MigrationError(HotReloadError):
    """Error during component migration."""
    pass


class HotReloadManager:
    """Manages hot reload operations for content packs.

    Thread-safe manager that handles the complex lifecycle of
    loading, unloading, and reloading content packs at runtime.

    Example:
        manager = HotReloadManager(engine)

        # Load a new pack
        result = await manager.load_pack(my_pack)
        if not result.success:
            print(f"Failed: {result.error}")

        # Reload an existing pack
        result = await manager.reload_pack("my-pack", new_pack_instance)

        # Unload a pack
        result = await manager.unload_pack("my-pack")
    """

    def __init__(self, engine: "GameEngine") -> None:
        self._engine = engine
        self._lock = asyncio.Lock()
        self._reload_hooks: dict[str, list[Callable]] = {
            "pre_unload": [],
            "post_unload": [],
            "pre_load": [],
            "post_load": [],
            "pre_migrate": [],
            "post_migrate": [],
        }
        self._migrations: dict[str, list[ComponentMigration]] = {}
        self._logger = logging.getLogger(__name__)

    def register_hook(
        self, 
        event: str, 
        callback: Callable[[HotReloadContext], None],
    ) -> None:
        """Register a hook for hot reload events.

        Events: pre_unload, post_unload, pre_load, post_load,
                pre_migrate, post_migrate
        """
        if event not in self._reload_hooks:
            raise ValueError(f"Unknown hook event: {event}")
        self._reload_hooks[event].append(callback)

    def register_migration(
        self,
        pack_name: str,
        migration: ComponentMigration,
    ) -> None:
        """Register a component migration for a pack."""
        if pack_name not in self._migrations:
            self._migrations[pack_name] = []
        self._migrations[pack_name].append(migration)

    async def load_pack(
        self, 
        pack: "ContentPack",
        *,
        validate: bool = True,
        pause_ticks: bool = True,
    ) -> HotReloadResult:
        """Load a new content pack at runtime.

        Args:
            pack: Content pack instance to load
            validate: Whether to validate dependencies first
            pause_ticks: Whether to pause tick processing during load

        Returns:
            HotReloadResult with operation outcome
        """
        import time
        start = time.monotonic()
        pack_name = pack.manifest.name

        async with self._lock:
            context = HotReloadContext(
                pack_name=pack_name,
                operation="load",
                state=HotReloadState.PENDING,
                engine=self._engine,
            )

            try:
                # Validate
                if validate:
                    context.state = HotReloadState.VALIDATING
                    self._validate_load(pack)

                # Pause ticks
                if pause_ticks:
                    context.state = HotReloadState.PAUSING
                    async with self._pause_ticks():
                        return await self._do_load(pack, context, start)
                else:
                    return await self._do_load(pack, context, start)

            except Exception as e:
                context.state = HotReloadState.FAILED
                return HotReloadResult(
                    success=False,
                    pack_name=pack_name,
                    operation="load",
                    state=context.state,
                    duration_ms=(time.monotonic() - start) * 1000,
                    error=e,
                )

    async def unload_pack(
        self,
        pack_name: str,
        *,
        force: bool = False,
        pause_ticks: bool = True,
    ) -> HotReloadResult:
        """Unload a content pack at runtime.

        Args:
            pack_name: Name of pack to unload
            force: If True, unload even if other packs depend on it
            pause_ticks: Whether to pause tick processing during unload

        Returns:
            HotReloadResult with operation outcome
        """
        import time
        start = time.monotonic()

        async with self._lock:
            context = HotReloadContext(
                pack_name=pack_name,
                operation="unload",
                state=HotReloadState.PENDING,
                engine=self._engine,
            )

            try:
                # Check dependencies unless forced
                if not force:
                    context.state = HotReloadState.VALIDATING
                    self._validate_unload(pack_name)

                # Pause ticks
                if pause_ticks:
                    context.state = HotReloadState.PAUSING
                    async with self._pause_ticks():
                        return await self._do_unload(pack_name, context, start)
                else:
                    return await self._do_unload(pack_name, context, start)

            except Exception as e:
                context.state = HotReloadState.FAILED
                return HotReloadResult(
                    success=False,
                    pack_name=pack_name,
                    operation="unload",
                    state=context.state,
                    duration_ms=(time.monotonic() - start) * 1000,
                    error=e,
                )

    async def reload_pack(
        self,
        pack_name: str,
        new_pack: "ContentPack | None" = None,
        *,
        migrate_data: bool = True,
    ) -> HotReloadResult:
        """Reload a content pack (unload + load atomically).

        Args:
            pack_name: Name of pack to reload
            new_pack: New pack instance (if None, re-instantiate from class)
            migrate_data: Whether to run component migrations

        Returns:
            HotReloadResult with operation outcome
        """
        import time
        start = time.monotonic()

        async with self._lock:
            context = HotReloadContext(
                pack_name=pack_name,
                operation="reload",
                state=HotReloadState.PENDING,
                engine=self._engine,
            )

            try:
                # Get current pack
                current_pack = self._engine.content_packs.get(pack_name)
                if not current_pack:
                    raise HotReloadError(f"Pack '{pack_name}' not loaded")

                # Create new instance if not provided
                if new_pack is None:
                    new_pack = type(current_pack)()

                # Validate versions for migration
                if migrate_data:
                    context.migrations = self._migrations.get(pack_name, [])

                async with self._pause_ticks():
                    # Capture current state for rollback
                    snapshot = await self._capture_pack_state(pack_name)
                    context.rollback_actions.append(
                        lambda: self._restore_pack_state(snapshot)
                    )

                    # Unload
                    context.state = HotReloadState.UNLOADING
                    await self._run_hooks("pre_unload", context)
                    await self._unload_pack_internal(pack_name, context)
                    await self._run_hooks("post_unload", context)

                    # Migrate data if needed
                    if migrate_data and context.migrations:
                        context.state = HotReloadState.MIGRATING
                        await self._run_hooks("pre_migrate", context)
                        migrated = await self._run_migrations(context)
                        await self._run_hooks("post_migrate", context)
                    else:
                        migrated = 0

                    # Load
                    context.state = HotReloadState.LOADING
                    await self._run_hooks("pre_load", context)
                    await self._load_pack_internal(new_pack, context)
                    await self._run_hooks("post_load", context)

                    context.state = HotReloadState.COMPLETED
                    return HotReloadResult(
                        success=True,
                        pack_name=pack_name,
                        operation="reload",
                        state=context.state,
                        duration_ms=(time.monotonic() - start) * 1000,
                        migrated_entities=migrated,
                    )

            except Exception as e:
                # Rollback
                context.state = HotReloadState.ROLLED_BACK
                for action in reversed(context.rollback_actions):
                    try:
                        action()
                    except Exception as rollback_err:
                        self._logger.error(f"Rollback failed: {rollback_err}")

                return HotReloadResult(
                    success=False,
                    pack_name=pack_name,
                    operation="reload",
                    state=context.state,
                    duration_ms=(time.monotonic() - start) * 1000,
                    error=e,
                )

    @asynccontextmanager
    async def _pause_ticks(self) -> AsyncIterator[None]:
        """Context manager to pause tick processing."""
        # Store current tick task
        tick_task = self._engine._tick_task
        paused_event = asyncio.Event()

        # Signal tick loop to pause
        self._engine._hot_reload_pause = paused_event

        try:
            # Wait for current tick to complete
            await asyncio.sleep(0)
            yield
        finally:
            # Resume tick loop
            self._engine._hot_reload_pause = None
            paused_event.set()

    def _validate_load(self, pack: "ContentPack") -> None:
        """Validate pack can be loaded."""
        pack_name = pack.manifest.name

        # Check not already loaded
        if pack_name in self._engine.content_packs:
            raise HotReloadError(f"Pack '{pack_name}' is already loaded")

        # Check dependencies are loaded
        for dep in pack.get_dependencies():
            if dep not in self._engine.content_packs:
                raise HotReloadError(
                    f"Pack '{pack_name}' requires '{dep}' which is not loaded"
                )

    def _validate_unload(self, pack_name: str) -> None:
        """Validate pack can be unloaded."""
        # Check pack exists
        if pack_name not in self._engine.content_packs:
            raise HotReloadError(f"Pack '{pack_name}' is not loaded")

        # Check no dependents
        dependents = []
        for name, pack in self._engine.content_packs.items():
            if name != pack_name and pack_name in pack.get_dependencies():
                dependents.append(name)

        if dependents:
            raise DependencyViolationError(pack_name, dependents)

    async def _do_load(
        self, 
        pack: "ContentPack", 
        context: HotReloadContext,
        start: float,
    ) -> HotReloadResult:
        """Execute pack loading."""
        import time

        context.state = HotReloadState.LOADING
        await self._run_hooks("pre_load", context)
        await self._load_pack_internal(pack, context)
        await self._run_hooks("post_load", context)

        context.state = HotReloadState.COMPLETED
        return HotReloadResult(
            success=True,
            pack_name=pack.manifest.name,
            operation="load",
            state=context.state,
            duration_ms=(time.monotonic() - start) * 1000,
        )

    async def _do_unload(
        self,
        pack_name: str,
        context: HotReloadContext,
        start: float,
    ) -> HotReloadResult:
        """Execute pack unloading."""
        import time

        context.state = HotReloadState.UNLOADING
        await self._run_hooks("pre_unload", context)
        orphaned = await self._unload_pack_internal(pack_name, context)
        await self._run_hooks("post_unload", context)

        context.state = HotReloadState.COMPLETED
        return HotReloadResult(
            success=True,
            pack_name=pack_name,
            operation="unload",
            state=context.state,
            duration_ms=(time.monotonic() - start) * 1000,
            orphaned_components=orphaned,
        )

    async def _load_pack_internal(
        self, 
        pack: "ContentPack",
        context: HotReloadContext,
    ) -> None:
        """Internal pack loading logic."""
        pack_name = pack.manifest.name
        world = self._engine.world

        # Set priority (based on load order)
        priority = len(self._engine._content_pack_order) * 10
        self._engine._command_registry.set_pack_priority(pack_name, priority)

        # Register document schemas
        pack.register_document_schemas(self._engine._document_store)

        # Register commands
        pack.register_commands(self._engine._command_registry)

        # Register and start systems
        for system in pack.get_systems(world):
            world.systems.register(system)
            await system.startup()

            # Auto-inject storage
            from maid_engine.storage.protocols import StorageAware
            if isinstance(system, StorageAware):
                system.set_storage(self._engine._document_store)

        # Track in engine
        self._engine._content_packs[pack_name] = pack
        self._engine._content_pack_order.append(pack_name)

        # Call pack's on_load
        await pack.on_load(self._engine)

    async def _unload_pack_internal(
        self,
        pack_name: str,
        context: HotReloadContext,
    ) -> int:
        """Internal pack unloading logic. Returns orphaned component count."""
        pack = self._engine._content_packs[pack_name]
        world = self._engine.world

        # Call pack's on_unload
        await pack.on_unload(self._engine)

        # Shutdown and unregister systems
        for system in pack.get_systems(world):
            system_type = type(system)
            existing = world.systems.get(system_type)
            if existing:
                await existing.shutdown()
                world.systems.unregister(system_type)

        # Unregister commands
        self._engine._command_registry.unregister_pack(pack_name)

        # Unregister event handlers (tracked by pack)
        orphaned = self._cleanup_orphaned_components(pack_name)

        # Remove from engine tracking
        del self._engine._content_packs[pack_name]
        self._engine._content_pack_order.remove(pack_name)

        return orphaned

    def _cleanup_orphaned_components(self, pack_name: str) -> int:
        """Remove or mark orphaned components. Returns count."""
        # TODO: Implement component type tracking per pack
        return 0

    async def _capture_pack_state(self, pack_name: str) -> dict[str, Any]:
        """Capture pack state for rollback."""
        pack = self._engine._content_packs[pack_name]
        return {
            "pack": pack,
            "order_index": self._engine._content_pack_order.index(pack_name),
            "commands": self._engine._command_registry.get_all_layers(pack_name),
        }

    def _restore_pack_state(self, snapshot: dict[str, Any]) -> None:
        """Restore pack state from snapshot."""
        # TODO: Implement state restoration
        pass

    async def _run_migrations(self, context: HotReloadContext) -> int:
        """Run component migrations. Returns migrated entity count."""
        migrated = 0
        world = self._engine.world

        for migration in context.migrations:
            # Find all entities with this component type
            for entity in world.entities:
                for comp in entity.components:
                    if comp.component_type == migration.component_type:
                        # Get component data
                        data = comp.model_dump()

                        # Run migration function
                        new_data = migration.migration_fn(data)

                        # Update component
                        for key, value in new_data.items():
                            if hasattr(comp, key):
                                setattr(comp, key, value)

                        migrated += 1
                        context.affected_entities.add(entity.id)

        return migrated

    async def _run_hooks(
        self, 
        event: str, 
        context: HotReloadContext,
    ) -> None:
        """Run registered hooks for an event."""
        for hook in self._reload_hooks.get(event, []):
            try:
                result = hook(context)
                if asyncio.iscoroutine(result):
                    await result
            except Exception as e:
                self._logger.warning(f"Hook {event} failed: {e}")

1.4.2 Enhanced GameEngine Integration

# Additions to GameEngine class

class GameEngine:
    def __init__(self):  # ... existing params ...
        # ... existing code ...
        self._hot_reload_manager: HotReloadManager | None = None
        self._hot_reload_pause: asyncio.Event | None = None

    @property
    def hot_reload(self) -> HotReloadManager:
        """Get the hot reload manager."""
        if self._hot_reload_manager is None:
            self._hot_reload_manager = HotReloadManager(self)
        return self._hot_reload_manager

    async def _tick_loop(self) -> None:
        """Modified tick loop with hot reload support."""
        while not self._stop_event.is_set():
            # Check for hot reload pause
            if self._hot_reload_pause is not None:
                await self._hot_reload_pause.wait()
                continue

            # ... rest of existing tick loop code ...

1.4.3 File Watcher for Development Mode

"""File watcher for automatic hot reload during development."""

import asyncio
from pathlib import Path
from typing import TYPE_CHECKING
from watchfiles import awatch, Change

if TYPE_CHECKING:
    from maid_engine.core.engine import GameEngine


class PackFileWatcher:
    """Watches content pack directories for changes.

    Example:
        watcher = PackFileWatcher(engine)
        watcher.watch("/path/to/my-pack")
        await watcher.start()
    """

    def __init__(self, engine: "GameEngine") -> None:
        self._engine = engine
        self._watch_paths: dict[str, Path] = {}  # pack_name -> path
        self._task: asyncio.Task | None = None
        self._debounce_delay = 0.5  # seconds

    def watch(self, pack_name: str, path: Path | str) -> None:
        """Add a pack directory to watch."""
        self._watch_paths[pack_name] = Path(path)

    def unwatch(self, pack_name: str) -> None:
        """Remove a pack from watch."""
        self._watch_paths.pop(pack_name, None)

    async def start(self) -> None:
        """Start watching for changes."""
        if self._task is not None:
            return
        self._task = asyncio.create_task(self._watch_loop())

    async def stop(self) -> None:
        """Stop watching."""
        if self._task:
            self._task.cancel()
            try:
                await self._task
            except asyncio.CancelledError:
                pass
            self._task = None

    async def _watch_loop(self) -> None:
        """Main watch loop."""
        paths = list(self._watch_paths.values())
        if not paths:
            return

        async for changes in awatch(*paths):
            # Debounce rapid changes
            await asyncio.sleep(self._debounce_delay)

            # Group changes by pack
            packs_to_reload: set[str] = set()
            for change_type, change_path in changes:
                if change_type in (Change.modified, Change.added):
                    path = Path(change_path)
                    for pack_name, watch_path in self._watch_paths.items():
                        if path.is_relative_to(watch_path):
                            packs_to_reload.add(pack_name)

            # Reload affected packs
            for pack_name in packs_to_reload:
                result = await self._engine.hot_reload.reload_pack(pack_name)
                if result.success:
                    print(f"[Hot Reload] Reloaded {pack_name}")
                else:
                    print(f"[Hot Reload] Failed to reload {pack_name}: {result.error}")

1.4.4 Event Types for Hot Reload

"""Hot reload events."""

from dataclasses import dataclass
from maid_engine.core.events import Event


@dataclass
class PackLoadingEvent(Event):
    """Emitted before a pack starts loading."""
    pack_name: str
    is_reload: bool = False


@dataclass
class PackLoadedEvent(Event):
    """Emitted after a pack has loaded."""
    pack_name: str
    is_reload: bool = False
    duration_ms: float = 0.0


@dataclass
class PackUnloadingEvent(Event):
    """Emitted before a pack starts unloading."""
    pack_name: str


@dataclass
class PackUnloadedEvent(Event):
    """Emitted after a pack has unloaded."""
    pack_name: str
    orphaned_components: int = 0


@dataclass
class HotReloadFailedEvent(Event):
    """Emitted when a hot reload fails."""
    pack_name: str
    operation: str
    error: str
    rolled_back: bool = False

1.5 Configuration

# Environment variables for hot reload configuration

# Enable/disable hot reload capability (default: true in dev, false in prod)
MAID_HOT_RELOAD__ENABLED=true

# Enable file watching for auto-reload (default: false)
MAID_HOT_RELOAD__FILE_WATCH=true

# Directories to watch (comma-separated)
MAID_HOT_RELOAD__WATCH_PATHS=/path/to/packs,/path/to/dev

# Debounce delay for file changes (seconds)
MAID_HOT_RELOAD__DEBOUNCE_DELAY=0.5

# Maximum time to wait for tick pause (seconds)
MAID_HOT_RELOAD__PAUSE_TIMEOUT=5.0

# Enable rollback on failure (default: true)
MAID_HOT_RELOAD__ENABLE_ROLLBACK=true

# Log level for hot reload operations
MAID_HOT_RELOAD__LOG_LEVEL=INFO

Settings Class Addition:

# In maid_engine/config/settings.py

class HotReloadSettings(BaseSettings):
    """Hot reload configuration."""

    enabled: bool = True
    file_watch: bool = False
    watch_paths: list[Path] = []
    debounce_delay: float = 0.5
    pause_timeout: float = 5.0
    enable_rollback: bool = True
    log_level: str = "INFO"

    model_config = SettingsConfigDict(
        env_prefix="MAID_HOT_RELOAD__",
    )


class Settings(BaseSettings):
    # ... existing fields ...
    hot_reload: HotReloadSettings = HotReloadSettings()

1.6 Dependencies

Library Version Purpose
watchfiles >=0.21.0 High-performance file watching for auto-reload
asyncio stdlib Async primitives (Lock, Event)

pyproject.toml addition:

[project.optional-dependencies]
dev = [
    "watchfiles>=0.21.0",
    # ... existing dev deps ...
]

1.7 Implementation Tasks

Phase 1: Core Hot Reload (P0)

  • [ ] Create maid_engine/plugins/hot_reload.py module
  • [ ] Implement HotReloadManager class
  • [ ] Implement HotReloadResult and HotReloadContext dataclasses
  • [ ] Implement HotReloadState enum
  • [ ] Implement HotReloadError exception hierarchy
  • [ ] Add _hot_reload_pause support to GameEngine._tick_loop()
  • [ ] Implement _pause_ticks() context manager
  • [ ] Implement load_pack() with validation
  • [ ] Implement unload_pack() with dependency checking
  • [ ] Implement reload_pack() with atomic unload/load
  • [ ] Add hot_reload property to GameEngine
  • [ ] Implement pack state capture for rollback
  • [ ] Implement rollback on failure
  • [ ] Add hot reload events (PackLoadedEvent, etc.)
  • [ ] Write unit tests for HotReloadManager
  • [ ] Write integration tests for hot reload with running engine

Phase 2: ECS Integration (P0)

  • [ ] Track systems by source pack in SystemManager
  • [ ] Implement safe system removal during tick pause
  • [ ] Implement system startup on hot load
  • [ ] Implement system shutdown on hot unload
  • [ ] Preserve entity data when system replaced
  • [ ] Write tests for ECS hot reload scenarios

Phase 3: Event/Command Integration (P0)

  • [ ] Track event handlers by source pack in EventBus
  • [ ] Implement handler unsubscription on pack unload
  • [ ] Preserve pending events during hot reload
  • [ ] Test command registry layering on hot reload
  • [ ] Verify command fallback when higher-priority removed

Phase 4: Data Migration (P1)

  • [ ] Implement ComponentMigration dataclass
  • [ ] Implement migration registration API
  • [ ] Implement _run_migrations() method
  • [ ] Handle orphaned components (type removed)
  • [ ] Write migration integration tests

Phase 5: File Watching (P2)

  • [ ] Create maid_engine/plugins/file_watcher.py
  • [ ] Implement PackFileWatcher class
  • [ ] Add watchfiles dependency to dev extras
  • [ ] Add hot reload settings to configuration
  • [ ] Integrate file watcher with CLI dev mode
  • [ ] Write file watcher tests

Phase 6: CLI/Admin Commands (P1-P2)

  • [ ] Add maid pack reload <pack-name> CLI command
  • [ ] Add maid pack load <path> CLI command
  • [ ] Add maid pack unload <pack-name> CLI command
  • [ ] Add maid pack watch <pack-name> CLI command
  • [ ] Add in-game @reload <pack> admin command
  • [ ] Add in-game @packs admin command to list status

1.8 Testing Requirements

Unit Tests

# tests/plugins/test_hot_reload.py

import pytest
from maid_engine.plugins.hot_reload import (
    HotReloadManager,
    HotReloadResult,
    HotReloadState,
    DependencyViolationError,
)


class TestHotReloadManager:
    """Tests for HotReloadManager."""

    @pytest.fixture
    def engine(self):
        """Create a test engine."""
        from maid_engine.core.engine import GameEngine
        return GameEngine()

    @pytest.fixture
    def manager(self, engine):
        """Create a hot reload manager."""
        return HotReloadManager(engine)

    @pytest.fixture
    def mock_pack(self):
        """Create a mock content pack."""
        # ... implementation

    async def test_load_pack_while_running(self, manager, mock_pack):
        """Test loading a pack while engine is running."""
        await manager._engine.start()
        result = await manager.load_pack(mock_pack)
        assert result.success
        assert result.state == HotReloadState.COMPLETED

    async def test_load_duplicate_pack_fails(self, manager, mock_pack):
        """Test that loading an already-loaded pack fails."""
        await manager.load_pack(mock_pack)
        result = await manager.load_pack(mock_pack)
        assert not result.success
        assert "already loaded" in str(result.error)

    async def test_unload_with_dependents_fails(self, manager):
        """Test that unloading a pack with dependents fails."""
        # Load pack A
        # Load pack B that depends on A
        # Try to unload A - should fail

    async def test_reload_preserves_entity_data(self, manager):
        """Test that entity data survives reload."""

    async def test_rollback_on_load_failure(self, manager):
        """Test that state is rolled back on load failure."""

    async def test_tick_paused_during_reload(self, manager):
        """Test that ticks are paused during reload."""

    async def test_component_migration(self, manager):
        """Test component data migration during reload."""

Integration Tests

# tests/plugins/test_hot_reload_integration.py

async def test_hot_reload_full_cycle():
    """Integration test: load, use, reload, verify."""
    engine = GameEngine()
    await engine.start()

    # Load initial pack
    pack_v1 = MyContentPackV1()
    result = await engine.hot_reload.load_pack(pack_v1)
    assert result.success

    # Create entities using pack
    entity = engine.world.create_entity()
    entity.add(MyComponent(value=42))

    # Reload with new version
    pack_v2 = MyContentPackV2()
    result = await engine.hot_reload.reload_pack("my-pack", pack_v2)
    assert result.success

    # Verify entity data preserved
    assert entity.get(MyComponent).value == 42

    await engine.stop()


async def test_hot_reload_command_fallback():
    """Test that command falls back to lower priority on unload."""
    engine = GameEngine()
    await engine.start()

    # Load stdlib with "look" command at priority 10
    engine.hot_reload.load_pack(StdlibPack())

    # Load custom pack that overrides "look" at priority 20
    engine.hot_reload.load_pack(CustomPack())

    # Verify custom "look" is active
    cmd = engine.command_registry.get("look")
    assert cmd.pack_name == "custom-pack"

    # Unload custom pack
    await engine.hot_reload.unload_pack("custom-pack")

    # Verify stdlib "look" is now active
    cmd = engine.command_registry.get("look")
    assert cmd.pack_name == "stdlib"

1.9 Acceptance Criteria

ID Criterion Verification Method
AC-1.1 Content packs can be loaded while engine state is RUNNING Integration test
AC-1.2 Content packs can be unloaded while engine state is RUNNING Integration test
AC-1.3 Pack reload completes in < 500ms for typical pack Performance test
AC-1.4 Entity data is preserved through reload Unit test
AC-1.5 Tick processing pauses during hot reload Unit test
AC-1.6 Failed reload rolls back to previous state Unit test
AC-1.7 Commands fall back correctly on pack unload Integration test
AC-1.8 Event handlers are cleaned up on unload Unit test
AC-1.9 File watcher triggers reload on save Manual + Integration test
AC-1.10 CLI commands work for hot reload CLI test
AC-1.11 No memory leaks after 100 reload cycles Memory profiling test
AC-1.12 Connected players are not disconnected during reload Manual test

Feature 2: Plugin Ecosystem Infrastructure

2.1 Feature Overview

What it does:
Provides the infrastructure to grow MAID's plugin ecosystem from 3 built-in packs to 40+ community-contributed plugins, including a plugin registry, scaffolding tools, testing framework, and distribution mechanisms.

Why it's needed: - Evennia has 40+ contrib modules built over 15+ years - MAID needs tooling to accelerate community contributions - Developers need templates and scaffolding to create plugins quickly - Quality control mechanisms ensure plugins meet standards - Discoverability enables users to find and install plugins

2.2 User Stories

US-2.1: Quick Plugin Creation

As a developer new to MAID, I want to scaffold a new content pack from a template so that I can start building game content in minutes rather than hours.

US-2.2: Plugin Discovery

As a server operator, I want to browse available plugins in a registry so that I can find functionality I need without searching GitHub.

US-2.3: Plugin Installation

As a server operator, I want to install plugins with a single command so that I can add new features without manual setup.

US-2.4: Plugin Testing

As a plugin developer, I want to run standardized tests against my plugin so that I can verify it works with the current MAID version.

US-2.5: Plugin Publishing

As a plugin developer, I want to publish my plugin to the registry so that others can discover and use it.

US-2.6: Version Compatibility

As a server operator, I want to see which MAID versions a plugin is compatible with so that I don't install incompatible plugins.

2.3 Technical Requirements

2.3.1 Plugin Registry Requirements

ID Requirement Priority
REG-001 System SHALL provide a central registry of available plugins P0
REG-002 Registry SHALL store plugin metadata (name, version, dependencies, compatibility) P0
REG-003 Registry SHALL support search by name, keyword, category P0
REG-004 Registry SHALL support version constraint matching P0
REG-005 Registry SHALL be accessible via CLI and API P0
REG-006 Registry SHALL support plugin submission workflow P1
REG-007 Registry SHALL track download counts P1
REG-008 Registry SHALL support plugin verification/signing P2

2.3.2 Scaffolding Tool Requirements

ID Requirement Priority
SCF-001 Tool SHALL generate complete content pack skeleton P0
SCF-002 Tool SHALL support multiple templates (minimal, full, system-only) P0
SCF-003 Tool SHALL generate pyproject.toml with correct entry points P0
SCF-004 Tool SHALL generate test scaffolding P0
SCF-005 Tool SHALL prompt for metadata (name, author, license) P0
SCF-006 Tool SHALL validate pack name uniqueness against registry P1
SCF-007 Tool SHALL generate documentation templates P1
SCF-008 Tool SHALL support interactive and non-interactive modes P1

2.3.3 Testing Framework Requirements

ID Requirement Priority
TST-001 Framework SHALL provide base test classes for content packs P0
TST-002 Framework SHALL provide mock World, Engine, EventBus fixtures P0
TST-003 Framework SHALL validate pack protocol compliance P0
TST-004 Framework SHALL test command registration P0
TST-005 Framework SHALL test system lifecycle P0
TST-006 Framework SHALL provide compatibility matrix testing P1
TST-007 Framework SHALL support integration test scenarios P1

2.4 API/Interface Design

2.4.1 Plugin Registry Client

"""Plugin registry client for discovering and installing plugins."""

from __future__ import annotations

import asyncio
import json
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any
from urllib.parse import urljoin

import httpx


class PluginCategory(Enum):
    """Plugin categories for organization."""
    SYSTEMS = "systems"          # Combat, magic, crafting systems
    CONTENT = "content"          # Races, classes, items, areas
    UTILITIES = "utilities"      # Dev tools, admin commands
    INTEGRATION = "integration"  # External service bridges
    FULL_GAME = "full_game"     # Complete game implementations
    TUTORIAL = "tutorial"        # Learning examples


@dataclass
class PluginMetadata:
    """Metadata for a plugin in the registry."""
    name: str
    version: str
    display_name: str
    description: str
    authors: list[str]
    license: str
    homepage: str
    repository: str
    categories: list[PluginCategory]
    keywords: list[str]

    # Compatibility
    maid_version_min: str
    maid_version_max: str | None
    python_version_min: str

    # Dependencies
    dependencies: dict[str, str]  # pack_name -> version constraint
    pip_dependencies: list[str]

    # Stats
    downloads: int = 0
    stars: int = 0
    verified: bool = False

    # Timestamps
    created_at: str = ""
    updated_at: str = ""


@dataclass
class PluginSearchResult:
    """Result from a registry search."""
    plugins: list[PluginMetadata]
    total_count: int
    page: int
    per_page: int


@dataclass
class PluginVersion:
    """A specific version of a plugin."""
    version: str
    maid_version_min: str
    maid_version_max: str | None
    release_date: str
    changelog: str
    download_url: str
    checksum_sha256: str


class PluginRegistryClient:
    """Client for the MAID plugin registry.

    Example:
        client = PluginRegistryClient()

        # Search for plugins
        results = await client.search("combat", category=PluginCategory.SYSTEMS)

        # Get plugin details
        plugin = await client.get_plugin("maid-combat-extended")

        # Install a plugin
        await client.install("maid-combat-extended", version="1.0.0")
    """

    DEFAULT_REGISTRY_URL = "https://registry.dventuring.com/api/v1"

    def __init__(
        self, 
        registry_url: str | None = None,
        cache_dir: Path | None = None,
    ) -> None:
        self._registry_url = registry_url or self.DEFAULT_REGISTRY_URL
        self._cache_dir = cache_dir or Path.home() / ".maid" / "registry_cache"
        self._http = httpx.AsyncClient(timeout=30.0)
        self._cache_dir.mkdir(parents=True, exist_ok=True)

    async def search(
        self,
        query: str = "",
        *,
        category: PluginCategory | None = None,
        keywords: list[str] | None = None,
        maid_version: str | None = None,
        page: int = 1,
        per_page: int = 20,
    ) -> PluginSearchResult:
        """Search the registry for plugins.

        Args:
            query: Text search query
            category: Filter by category
            keywords: Filter by keywords
            maid_version: Filter by compatibility with MAID version
            page: Page number (1-indexed)
            per_page: Results per page

        Returns:
            PluginSearchResult with matching plugins
        """
        params = {
            "q": query,
            "page": page,
            "per_page": per_page,
        }
        if category:
            params["category"] = category.value
        if keywords:
            params["keywords"] = ",".join(keywords)
        if maid_version:
            params["maid_version"] = maid_version

        response = await self._http.get(
            urljoin(self._registry_url, "/plugins"),
            params=params,
        )
        response.raise_for_status()
        data = response.json()

        return PluginSearchResult(
            plugins=[PluginMetadata(**p) for p in data["plugins"]],
            total_count=data["total_count"],
            page=data["page"],
            per_page=data["per_page"],
        )

    async def get_plugin(self, name: str) -> PluginMetadata | None:
        """Get plugin metadata by name."""
        try:
            response = await self._http.get(
                urljoin(self._registry_url, f"/plugins/{name}")
            )
            response.raise_for_status()
            return PluginMetadata(**response.json())
        except httpx.HTTPStatusError as e:
            if e.response.status_code == 404:
                return None
            raise

    async def get_versions(self, name: str) -> list[PluginVersion]:
        """Get all versions of a plugin."""
        response = await self._http.get(
            urljoin(self._registry_url, f"/plugins/{name}/versions")
        )
        response.raise_for_status()
        return [PluginVersion(**v) for v in response.json()]

    async def install(
        self,
        name: str,
        *,
        version: str | None = None,
        upgrade: bool = False,
    ) -> Path:
        """Install a plugin from the registry.

        Args:
            name: Plugin name
            version: Specific version (default: latest compatible)
            upgrade: If True, upgrade if already installed

        Returns:
            Path to installed plugin
        """
        import subprocess

        # Get plugin metadata
        plugin = await self.get_plugin(name)
        if not plugin:
            raise ValueError(f"Plugin '{name}' not found in registry")

        # Determine version to install
        if version is None:
            versions = await self.get_versions(name)
            # Find latest compatible version
            version = self._find_compatible_version(versions)

        # Install via pip
        package_spec = f"{name}=={version}" if version else name
        cmd = ["pip", "install"]
        if upgrade:
            cmd.append("--upgrade")
        cmd.append(package_spec)

        result = subprocess.run(cmd, capture_output=True, text=True)
        if result.returncode != 0:
            raise RuntimeError(f"pip install failed: {result.stderr}")

        # Return installation path
        import importlib.util
        spec = importlib.util.find_spec(name.replace("-", "_"))
        if spec and spec.origin:
            return Path(spec.origin).parent
        return Path()

    async def uninstall(self, name: str) -> bool:
        """Uninstall a plugin."""
        import subprocess
        result = subprocess.run(
            ["pip", "uninstall", "-y", name],
            capture_output=True,
            text=True,
        )
        return result.returncode == 0

    async def list_installed(self) -> list[tuple[str, str]]:
        """List installed MAID plugins.

        Returns:
            List of (name, version) tuples
        """
        import importlib.metadata

        installed = []
        for ep in importlib.metadata.entry_points(group="maid.content_packs"):
            try:
                dist = importlib.metadata.distribution(ep.name)
                installed.append((ep.name, dist.version))
            except importlib.metadata.PackageNotFoundError:
                pass
        return installed

    def _find_compatible_version(
        self, 
        versions: list[PluginVersion],
    ) -> str:
        """Find latest version compatible with current MAID."""
        from maid_engine import __version__ as maid_version
        from packaging.version import Version
        from packaging.specifiers import SpecifierSet

        current = Version(maid_version)

        for v in sorted(versions, key=lambda x: Version(x.version), reverse=True):
            spec = SpecifierSet(f">={v.maid_version_min}")
            if v.maid_version_max:
                spec &= SpecifierSet(f"<={v.maid_version_max}")
            if current in spec:
                return v.version

        raise ValueError("No compatible version found")

    async def close(self) -> None:
        """Close the HTTP client."""
        await self._http.aclose()


# Convenience function
async def search_plugins(query: str, **kwargs) -> PluginSearchResult:
    """Search the plugin registry."""
    async with PluginRegistryClient() as client:
        return await client.search(query, **kwargs)

2.4.2 Plugin Scaffolding Tool

"""Plugin scaffolding tool for creating new content packs."""

from __future__ import annotations

import re
from dataclasses import dataclass, field
from enum import Enum
from pathlib import Path
from typing import Any

import jinja2


class TemplateType(Enum):
    """Available plugin templates."""
    MINIMAL = "minimal"     # Just manifest and pack class
    STANDARD = "standard"   # Systems, commands, events
    FULL = "full"          # Complete with tests, docs, CI
    SYSTEM_ONLY = "system" # Just ECS systems
    COMMAND_ONLY = "command"  # Just commands


@dataclass
class PluginConfig:
    """Configuration for a new plugin."""
    name: str               # e.g., "maid-my-plugin"
    display_name: str       # e.g., "My Plugin"
    description: str
    author: str
    author_email: str
    license: str = "MIT"
    repository: str = ""
    homepage: str = ""

    # Dependencies
    dependencies: list[str] = field(default_factory=lambda: ["maid-stdlib"])
    python_requires: str = ">=3.12"
    maid_requires: str = ">=0.1.0"

    # Features
    include_tests: bool = True
    include_docs: bool = True
    include_ci: bool = True

    def __post_init__(self):
        # Normalize name
        self.name = self.name.lower().replace("_", "-")
        if not self.name.startswith("maid-"):
            self.name = f"maid-{self.name}"

    @property
    def package_name(self) -> str:
        """Python package name (underscores)."""
        return self.name.replace("-", "_")

    @property
    def class_name(self) -> str:
        """ContentPack class name."""
        parts = self.name.replace("maid-", "").split("-")
        return "".join(p.title() for p in parts) + "ContentPack"


class PluginScaffolder:
    """Scaffolds new MAID content pack projects.

    Example:
        scaffolder = PluginScaffolder()

        config = PluginConfig(
            name="my-combat-pack",
            display_name="My Combat Pack",
            description="Enhanced combat mechanics",
            author="Jane Developer",
            author_email="jane@example.com",
        )

        scaffolder.create(config, template=TemplateType.STANDARD)
    """

    TEMPLATES_DIR = Path(__file__).parent / "templates"

    def __init__(self) -> None:
        self._jinja = jinja2.Environment(
            loader=jinja2.FileSystemLoader(str(self.TEMPLATES_DIR)),
            keep_trailing_newline=True,
        )

    def create(
        self,
        config: PluginConfig,
        template: TemplateType = TemplateType.STANDARD,
        output_dir: Path | None = None,
    ) -> Path:
        """Create a new content pack project.

        Args:
            config: Plugin configuration
            template: Template type to use
            output_dir: Output directory (default: current directory)

        Returns:
            Path to created project
        """
        output_dir = output_dir or Path.cwd()
        project_dir = output_dir / config.name

        if project_dir.exists():
            raise ValueError(f"Directory already exists: {project_dir}")

        # Create directory structure
        self._create_structure(project_dir, config, template)

        # Generate files from templates
        self._generate_files(project_dir, config, template)

        return project_dir

    def _create_structure(
        self,
        project_dir: Path,
        config: PluginConfig,
        template: TemplateType,
    ) -> None:
        """Create directory structure."""
        dirs = [
            project_dir,
            project_dir / "src" / config.package_name,
        ]

        if template in (TemplateType.STANDARD, TemplateType.FULL):
            dirs.extend([
                project_dir / "src" / config.package_name / "systems",
                project_dir / "src" / config.package_name / "commands",
                project_dir / "src" / config.package_name / "events",
                project_dir / "src" / config.package_name / "components",
            ])

        if config.include_tests:
            dirs.append(project_dir / "tests")

        if config.include_docs:
            dirs.append(project_dir / "docs")

        if config.include_ci:
            dirs.append(project_dir / ".github" / "workflows")

        for d in dirs:
            d.mkdir(parents=True, exist_ok=True)

    def _generate_files(
        self,
        project_dir: Path,
        config: PluginConfig,
        template: TemplateType,
    ) -> None:
        """Generate project files from templates."""
        context = {
            "config": config,
            "template": template,
        }

        # Always generate these
        files = [
            ("pyproject.toml.j2", "pyproject.toml"),
            ("README.md.j2", "README.md"),
            ("__init__.py.j2", f"src/{config.package_name}/__init__.py"),
            ("pack.py.j2", f"src/{config.package_name}/pack.py"),
            ("py.typed.j2", f"src/{config.package_name}/py.typed"),
        ]

        if template in (TemplateType.STANDARD, TemplateType.FULL):
            files.extend([
                ("systems/__init__.py.j2", f"src/{config.package_name}/systems/__init__.py"),
                ("commands/__init__.py.j2", f"src/{config.package_name}/commands/__init__.py"),
                ("events/__init__.py.j2", f"src/{config.package_name}/events/__init__.py"),
                ("components/__init__.py.j2", f"src/{config.package_name}/components/__init__.py"),
            ])

        if template == TemplateType.FULL:
            files.extend([
                ("systems/example_system.py.j2", f"src/{config.package_name}/systems/example_system.py"),
                ("commands/example_commands.py.j2", f"src/{config.package_name}/commands/example_commands.py"),
            ])

        if config.include_tests:
            files.extend([
                ("tests/__init__.py.j2", "tests/__init__.py"),
                ("tests/conftest.py.j2", "tests/conftest.py"),
                ("tests/test_pack.py.j2", "tests/test_pack.py"),
            ])

        if config.include_docs:
            files.extend([
                ("docs/index.md.j2", "docs/index.md"),
                ("docs/installation.md.j2", "docs/installation.md"),
                ("docs/usage.md.j2", "docs/usage.md"),
            ])

        if config.include_ci:
            files.extend([
                (".github/workflows/ci.yml.j2", ".github/workflows/ci.yml"),
                (".github/workflows/release.yml.j2", ".github/workflows/release.yml"),
            ])

        # Generate each file
        for template_name, output_path in files:
            self._render_template(
                project_dir,
                template_name,
                output_path,
                context,
            )

    def _render_template(
        self,
        project_dir: Path,
        template_name: str,
        output_path: str,
        context: dict[str, Any],
    ) -> None:
        """Render a template to a file."""
        try:
            template = self._jinja.get_template(template_name)
            content = template.render(**context)

            output_file = project_dir / output_path
            output_file.parent.mkdir(parents=True, exist_ok=True)
            output_file.write_text(content)
        except jinja2.TemplateNotFound:
            # Template doesn't exist, skip
            pass


# CLI integration
def create_plugin_interactive() -> Path:
    """Interactive plugin creation wizard."""
    import questionary

    # Gather input
    name = questionary.text(
        "Plugin name (e.g., 'my-combat-system'):",
        validate=lambda x: bool(re.match(r'^[a-z][a-z0-9-]*$', x)),
    ).ask()

    display_name = questionary.text(
        "Display name:",
        default=name.replace("-", " ").title(),
    ).ask()

    description = questionary.text("Description:").ask()

    author = questionary.text("Author name:").ask()
    author_email = questionary.text("Author email:").ask()

    template = questionary.select(
        "Template type:",
        choices=[
            questionary.Choice("Minimal (just pack class)", TemplateType.MINIMAL),
            questionary.Choice("Standard (systems, commands, events)", TemplateType.STANDARD),
            questionary.Choice("Full (complete with tests, docs, CI)", TemplateType.FULL),
        ],
    ).ask()

    license_choice = questionary.select(
        "License:",
        choices=["MIT", "Apache-2.0", "GPL-3.0", "BSD-3-Clause", "Proprietary"],
    ).ask()

    # Create config
    config = PluginConfig(
        name=name,
        display_name=display_name,
        description=description,
        author=author,
        author_email=author_email,
        license=license_choice,
        include_tests=template in (TemplateType.STANDARD, TemplateType.FULL),
        include_docs=template == TemplateType.FULL,
        include_ci=template == TemplateType.FULL,
    )

    # Scaffold
    scaffolder = PluginScaffolder()
    return scaffolder.create(config, template)

2.4.3 Plugin Testing Framework

"""Testing framework for MAID content packs."""

from __future__ import annotations

import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Generator
from unittest.mock import AsyncMock, MagicMock
from uuid import uuid4

import pytest

if TYPE_CHECKING:
    from maid_engine.core.engine import GameEngine
    from maid_engine.core.world import World
    from maid_engine.plugins.protocol import ContentPack


@dataclass
class MockSettings:
    """Mock settings for testing."""
    game: MagicMock = field(default_factory=lambda: MagicMock(tick_rate=4.0))
    telnet: MagicMock = field(default_factory=MagicMock)
    web: MagicMock = field(default_factory=MagicMock)


class ContentPackTestCase:
    """Base test case for content pack testing.

    Provides fixtures and utilities for testing content packs.

    Example:
        class TestMyCombatPack(ContentPackTestCase):
            def get_pack(self) -> ContentPack:
                return MyCombatContentPack()

            async def test_combat_system_registered(self):
                '''Test that combat system is registered.'''
                systems = self.pack.get_systems(self.world)
                assert any(
                    isinstance(s, CombatSystem) for s in systems
                )

            async def test_attack_command_exists(self):
                '''Test that attack command is registered.'''
                await self.load_pack()
                assert "attack" in self.engine.command_registry
    """

    pack: ContentPack
    engine: GameEngine
    world: World

    def get_pack(self) -> ContentPack:
        """Override to return the pack under test."""
        raise NotImplementedError("Subclass must implement get_pack()")

    def get_dependency_packs(self) -> list[ContentPack]:
        """Override to return packs this pack depends on."""
        return []

    @pytest.fixture(autouse=True)
    def setup(self) -> Generator[None, None, None]:
        """Set up test fixtures."""
        from maid_engine.core.engine import GameEngine
        from maid_engine.storage.document_store import InMemoryDocumentStore

        # Create engine with in-memory store
        self.engine = GameEngine(
            settings=MockSettings(),
            document_store=InMemoryDocumentStore(),
        )
        self.world = self.engine.world

        # Load dependency packs
        for dep_pack in self.get_dependency_packs():
            self.engine.load_content_pack(dep_pack)

        # Get pack instance
        self.pack = self.get_pack()

        yield

        # Cleanup
        asyncio.get_event_loop().run_until_complete(
            self.engine.stop()
        )

    async def load_pack(self) -> None:
        """Load the pack into the engine."""
        self.engine.load_content_pack(self.pack)
        await self.engine.start()

    # Protocol Compliance Tests

    def test_has_manifest(self) -> None:
        """Test that pack has a valid manifest."""
        manifest = self.pack.manifest
        assert manifest.name, "Manifest must have a name"
        assert manifest.version, "Manifest must have a version"

    def test_manifest_name_format(self) -> None:
        """Test that manifest name follows conventions."""
        import re
        name = self.pack.manifest.name
        assert re.match(r'^[a-z][a-z0-9-]*$', name), \
            f"Pack name '{name}' should be lowercase with hyphens"

    def test_dependencies_list(self) -> None:
        """Test that get_dependencies returns a list."""
        deps = self.pack.get_dependencies()
        assert isinstance(deps, list)

    def test_systems_list(self) -> None:
        """Test that get_systems returns a list of System instances."""
        from maid_engine.core.ecs.system import System
        systems = self.pack.get_systems(self.world)
        assert isinstance(systems, list)
        for system in systems:
            assert isinstance(system, System)

    def test_events_list(self) -> None:
        """Test that get_events returns a list of Event types."""
        from maid_engine.core.events import Event
        events = self.pack.get_events()
        assert isinstance(events, list)
        for event_type in events:
            assert issubclass(event_type, Event)

    async def test_on_load_completes(self) -> None:
        """Test that on_load completes without error."""
        await self.load_pack()
        # If we get here, on_load succeeded

    async def test_on_unload_completes(self) -> None:
        """Test that on_unload completes without error."""
        await self.load_pack()
        await self.pack.on_unload(self.engine)


class CompatibilityTestSuite:
    """Test suite for checking compatibility across MAID versions.

    Example:
        suite = CompatibilityTestSuite()
        results = await suite.test_pack(
            MyContentPack(),
            maid_versions=["0.1.0", "0.2.0", "0.3.0"],
        )
        for version, passed in results.items():
            print(f"MAID {version}: {'PASS' if passed else 'FAIL'}")
    """

    async def test_pack(
        self,
        pack: ContentPack,
        maid_versions: list[str],
    ) -> dict[str, bool]:
        """Test pack against multiple MAID versions.

        Uses tox or nox to test against multiple versions.
        """
        results = {}
        for version in maid_versions:
            try:
                # This would use tox/nox in practice
                results[version] = await self._test_version(pack, version)
            except Exception:
                results[version] = False
        return results

    async def _test_version(
        self,
        pack: ContentPack,
        maid_version: str,
    ) -> bool:
        """Test pack against specific MAID version."""
        # In practice, this would:
        # 1. Create a virtual environment
        # 2. Install specific MAID version
        # 3. Run pack tests
        # 4. Return success/failure
        return True


# Pytest fixtures for plugin testing

@pytest.fixture
def mock_world():
    """Fixture providing a mock World instance."""
    from maid_engine.core.world import World
    from maid_engine.core.ecs.entity import EntityManager
    from maid_engine.core.ecs.system import SystemManager
    from maid_engine.core.events import EventBus

    world = MagicMock(spec=World)
    world.entities = EntityManager()
    world.systems = SystemManager(world)
    world.events = EventBus()
    world._custom_data = {}
    return world


@pytest.fixture
def mock_engine(mock_world):
    """Fixture providing a mock GameEngine instance."""
    from maid_engine.core.engine import GameEngine
    from maid_engine.commands.registry import LayeredCommandRegistry
    from maid_engine.storage.document_store import InMemoryDocumentStore

    engine = MagicMock(spec=GameEngine)
    engine.world = mock_world
    engine._command_registry = LayeredCommandRegistry()
    engine._document_store = InMemoryDocumentStore()
    engine._content_packs = {}
    engine._content_pack_order = []
    return engine


@pytest.fixture
def test_entity(mock_world):
    """Fixture providing a test entity."""
    entity = mock_world.entities.create()
    return entity

2.5 Configuration

# Registry configuration
MAID_REGISTRY__URL=https://registry.dventuring.com/api/v1
MAID_REGISTRY__CACHE_DIR=~/.maid/registry_cache
MAID_REGISTRY__CACHE_TTL=3600  # seconds

# Scaffolding defaults
MAID_SCAFFOLD__DEFAULT_AUTHOR=
MAID_SCAFFOLD__DEFAULT_EMAIL=
MAID_SCAFFOLD__DEFAULT_LICENSE=MIT
MAID_SCAFFOLD__TEMPLATES_DIR=  # Custom templates

# Testing
MAID_TEST__MAID_VERSIONS=0.1.0,0.2.0  # For compatibility testing

2.6 Dependencies

Library Version Purpose
httpx >=0.25.0 Async HTTP client for registry
jinja2 >=3.1.0 Template rendering for scaffolding
questionary >=2.0.0 Interactive CLI prompts
packaging >=23.0 Version parsing and comparison

2.7 Implementation Tasks

Phase 1: Scaffolding Tool (P0)

  • [ ] Create template directory structure in maid_engine/plugins/templates/
  • [ ] Create pyproject.toml.j2 template
  • [ ] Create pack.py.j2 template
  • [ ] Create __init__.py.j2 templates
  • [ ] Create test templates (conftest.py.j2, test_pack.py.j2)
  • [ ] Implement PluginConfig dataclass
  • [ ] Implement PluginScaffolder class
  • [ ] Implement create_plugin_interactive() wizard
  • [ ] Add maid plugin new CLI command
  • [ ] Add maid plugin new --non-interactive flag
  • [ ] Write tests for scaffolder
  • [ ] Document scaffolding in user guide

Phase 2: Testing Framework (P0)

  • [ ] Create maid_engine/plugins/testing.py module
  • [ ] Implement ContentPackTestCase base class
  • [ ] Implement protocol compliance tests
  • [ ] Create pytest fixtures (mock_world, mock_engine)
  • [ ] Implement CompatibilityTestSuite
  • [ ] Add maid plugin test CLI command
  • [ ] Write tests for testing framework
  • [ ] Document testing patterns

Phase 3: Registry Client (P1)

  • [ ] Create maid_engine/plugins/registry.py module
  • [ ] Implement PluginMetadata dataclass
  • [ ] Implement PluginRegistryClient class
  • [ ] Implement search functionality
  • [ ] Implement install/uninstall
  • [ ] Add registry caching
  • [ ] Add maid plugin search CLI command
  • [ ] Add maid plugin install CLI command
  • [ ] Add maid plugin registry-list CLI command
  • [ ] Add maid plugin uninstall CLI command
  • [ ] Write tests for registry client

Phase 4: Registry Server (P2)

  • [ ] Design registry database schema
  • [ ] Implement registry API with FastAPI
  • [ ] Implement plugin submission workflow
  • [ ] Implement verification system
  • [ ] Deploy registry server
  • [ ] Create submission documentation

2.8 Testing Requirements

# tests/plugins/test_scaffolder.py

class TestPluginScaffolder:
    def test_creates_minimal_project(self, tmp_path):
        """Test minimal template creates correct structure."""
        config = PluginConfig(
            name="test-plugin",
            display_name="Test Plugin",
            description="A test plugin",
            author="Test Author",
            author_email="test@example.com",
        )
        scaffolder = PluginScaffolder()
        project = scaffolder.create(config, TemplateType.MINIMAL, tmp_path)

        assert (project / "pyproject.toml").exists()
        assert (project / "src" / "maid_test_plugin" / "pack.py").exists()

    def test_pyproject_has_entry_point(self, tmp_path):
        """Test generated pyproject.toml has correct entry point."""
        # ...

    def test_full_template_includes_ci(self, tmp_path):
        """Test full template includes CI configuration."""
        # ...


# tests/plugins/test_registry_client.py

class TestPluginRegistryClient:
    async def test_search_returns_results(self, mock_registry):
        """Test search returns plugin results."""
        # ...

    async def test_install_plugin(self, mock_registry, tmp_path):
        """Test installing a plugin."""
        # ...


# tests/plugins/test_testing_framework.py

class TestContentPackTestCase:
    def test_protocol_compliance_checks(self):
        """Test that protocol compliance is verified."""
        # ...

2.9 Acceptance Criteria

ID Criterion Verification Method
AC-2.1 maid plugin new creates working plugin in < 30 seconds Manual test
AC-2.2 Generated plugin passes all protocol compliance tests Automated test
AC-2.3 Registry search returns results in < 2 seconds Performance test
AC-2.4 Plugin installation works with maid plugin install Manual test
AC-2.5 Test framework catches protocol violations Unit test
AC-2.6 Scaffolder supports all template types Integration test
AC-2.7 Generated CI workflow passes in GitHub Actions CI test

Feature 3: Plugin Documentation System

3.1 Feature Overview

What it does:
Provides comprehensive documentation infrastructure for plugin authors and users, including auto-generated API documentation, interactive examples, migration guides, and searchable documentation sites.

Why it's needed: - Evennia has 500+ markdown documentation files built over 15 years - Plugin authors need clear guidance on how to build plugins - Users need to understand how to install, configure, and use plugins - API documentation must stay synchronized with code - Searchable documentation improves developer experience

3.2 User Stories

US-3.1: Finding Documentation

As a new MAID developer, I want to find comprehensive documentation for building content packs so that I can learn the system without reading source code.

US-3.2: API Reference

As a plugin developer, I want auto-generated API documentation so that I can see all available interfaces without manually reading docstrings.

US-3.3: Example Code

As a plugin developer, I want working example plugins so that I can learn by studying real implementations.

US-3.4: Migration Guides

As a plugin maintainer, I want migration guides when MAID releases new versions so that I can update my plugin without trial and error.

US-3.5: Plugin Documentation

As a plugin author, I want to generate documentation for my plugin using the same tools as MAID core so that my docs look professional and consistent.

3.3 Technical Requirements

3.3.1 Documentation Site Requirements

ID Requirement Priority
DOC-001 System SHALL provide a static documentation site P0
DOC-002 Documentation SHALL be searchable P0
DOC-003 Documentation SHALL include getting started guide P0
DOC-004 Documentation SHALL include API reference P0
DOC-005 Documentation SHALL include tutorial content P0
DOC-006 Documentation SHALL support versioned content P1
DOC-007 Documentation SHALL support dark/light mode P2

3.3.2 API Documentation Requirements

ID Requirement Priority
API-001 System SHALL auto-generate API docs from docstrings P0
API-002 API docs SHALL include type annotations P0
API-003 API docs SHALL include code examples P0
API-004 API docs SHALL be regenerated on release P0
API-005 System SHALL validate docstring coverage P1
API-006 System SHALL generate changelog from commits P1

3.3.3 Plugin Documentation Requirements

ID Requirement Priority
PLG-001 Scaffolder SHALL generate documentation templates P0
PLG-002 Plugin docs SHALL follow standard structure P0
PLG-003 System SHALL validate plugin documentation completeness P1
PLG-004 System SHALL aggregate plugin docs into registry P2

3.4 API/Interface Design

3.4.1 Documentation Structure

docs/
├── index.md                    # Landing page
├── getting-started/
│   ├── installation.md         # Installing MAID
│   ├── quickstart.md          # 5-minute quickstart
│   ├── first-plugin.md        # Creating your first plugin
│   └── concepts.md            # Core concepts overview
│
├── guides/
│   ├── content-packs/
│   │   ├── overview.md        # What are content packs
│   │   ├── creating.md        # Creating a content pack
│   │   ├── systems.md         # Building ECS systems
│   │   ├── commands.md        # Adding commands
│   │   ├── events.md          # Working with events
│   │   ├── persistence.md     # Data persistence
│   │   ├── testing.md         # Testing your pack
│   │   └── publishing.md      # Publishing to registry
│   │
│   ├── ecs/
│   │   ├── overview.md        # ECS architecture
│   │   ├── entities.md        # Working with entities
│   │   ├── components.md      # Creating components
│   │   └── systems.md         # Building systems
│   │
│   ├── events/
│   │   ├── overview.md        # Event system
│   │   ├── handlers.md        # Event handlers
│   │   └── custom-events.md   # Custom events
│   │
│   ├── commands/
│   │   ├── overview.md        # Command system
│   │   ├── handlers.md        # Command handlers
│   │   └── layering.md        # Command layering
│   │
│   ├── hot-reload/
│   │   ├── overview.md        # Hot reload system
│   │   ├── development.md     # Dev workflow
│   │   └── migrations.md      # Data migrations
│   │
│   └── advanced/
│       ├── multi-world.md     # Multi-world support
│       ├── ai-integration.md  # AI provider integration
│       └── performance.md     # Performance tuning
│
├── tutorials/
│   ├── combat-system/         # Build a combat system
│   │   ├── 01-setup.md
│   │   ├── 02-components.md
│   │   ├── 03-system.md
│   │   ├── 04-commands.md
│   │   └── 05-testing.md
│   │
│   ├── magic-system/          # Build a magic system
│   │   └── ...
│   │
│   └── complete-game/         # Build a complete game
│       └── ...
│
├── reference/
│   ├── api/                   # Auto-generated API docs
│   │   ├── maid_engine/
│   │   ├── maid_stdlib/
│   │   └── maid_classic_rpg/
│   │
│   ├── cli.md                 # CLI reference
│   ├── configuration.md       # All config options
│   ├── events.md              # All built-in events
│   └── components.md          # All built-in components
│
├── migration/
│   ├── 0.1-to-0.2.md         # Version migration guides
│   ├── 0.2-to-0.3.md
│   └── changelog.md           # Full changelog
│
└── contributing/
    ├── guidelines.md          # Contribution guidelines
    ├── code-style.md          # Code style guide
    ├── pull-requests.md       # PR process
    └── architecture.md        # Architecture decisions

3.4.2 Documentation Generator

"""Documentation generation utilities."""

from __future__ import annotations

import ast
import inspect
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, get_type_hints


@dataclass
class DocstringInfo:
    """Parsed docstring information."""
    summary: str
    description: str
    args: dict[str, str]
    returns: str
    raises: dict[str, str]
    examples: list[str]

    @classmethod
    def from_docstring(cls, docstring: str | None) -> "DocstringInfo":
        """Parse a Google-style docstring."""
        if not docstring:
            return cls("", "", {}, "", {}, [])

        # Parse Google-style docstring
        # ... implementation
        return cls(
            summary="",
            description="",
            args={},
            returns="",
            raises={},
            examples=[],
        )


@dataclass
class APIDoc:
    """Documentation for a single API element."""
    name: str
    kind: str  # "class", "function", "method", "property"
    signature: str
    docstring: DocstringInfo
    source_file: str
    source_line: int
    type_hints: dict[str, str] = field(default_factory=dict)
    members: list["APIDoc"] = field(default_factory=list)


class APIDocGenerator:
    """Generates API documentation from Python modules.

    Example:
        generator = APIDocGenerator()
        docs = generator.generate_module("maid_engine.core.engine")
        generator.write_markdown(docs, Path("docs/reference/api"))
    """

    def __init__(self) -> None:
        self._visited: set[str] = set()

    def generate_module(self, module_name: str) -> list[APIDoc]:
        """Generate API docs for a module."""
        import importlib

        module = importlib.import_module(module_name)
        docs = []

        for name, obj in inspect.getmembers(module):
            if name.startswith("_"):
                continue
            if inspect.isclass(obj):
                docs.append(self._document_class(obj, module_name))
            elif inspect.isfunction(obj):
                docs.append(self._document_function(obj, module_name))

        return docs

    def _document_class(self, cls: type, module: str) -> APIDoc:
        """Document a class."""
        members = []

        # Document methods
        for name, method in inspect.getmembers(cls, inspect.isfunction):
            if not name.startswith("_") or name in ("__init__", "__call__"):
                members.append(self._document_method(method, cls))

        # Document properties
        for name, prop in inspect.getmembers(cls, lambda x: isinstance(x, property)):
            if not name.startswith("_"):
                members.append(self._document_property(name, prop, cls))

        return APIDoc(
            name=cls.__name__,
            kind="class",
            signature=self._get_class_signature(cls),
            docstring=DocstringInfo.from_docstring(cls.__doc__),
            source_file=inspect.getfile(cls),
            source_line=inspect.getsourcelines(cls)[1],
            type_hints=self._get_type_hints(cls),
            members=members,
        )

    def _document_function(self, func: Any, module: str) -> APIDoc:
        """Document a function."""
        return APIDoc(
            name=func.__name__,
            kind="function",
            signature=self._get_function_signature(func),
            docstring=DocstringInfo.from_docstring(func.__doc__),
            source_file=inspect.getfile(func),
            source_line=inspect.getsourcelines(func)[1],
            type_hints=self._get_type_hints(func),
        )

    def _document_method(self, method: Any, cls: type) -> APIDoc:
        """Document a method."""
        return APIDoc(
            name=method.__name__,
            kind="method",
            signature=self._get_function_signature(method),
            docstring=DocstringInfo.from_docstring(method.__doc__),
            source_file=inspect.getfile(method),
            source_line=inspect.getsourcelines(method)[1],
            type_hints=self._get_type_hints(method),
        )

    def _document_property(self, name: str, prop: property, cls: type) -> APIDoc:
        """Document a property."""
        fget = prop.fget
        return APIDoc(
            name=name,
            kind="property",
            signature=f"@property",
            docstring=DocstringInfo.from_docstring(fget.__doc__ if fget else None),
            source_file=inspect.getfile(fget) if fget else "",
            source_line=inspect.getsourcelines(fget)[1] if fget else 0,
        )

    def _get_class_signature(self, cls: type) -> str:
        """Get class signature including bases."""
        bases = [b.__name__ for b in cls.__bases__ if b is not object]
        if bases:
            return f"class {cls.__name__}({', '.join(bases)})"
        return f"class {cls.__name__}"

    def _get_function_signature(self, func: Any) -> str:
        """Get function signature."""
        try:
            return str(inspect.signature(func))
        except (ValueError, TypeError):
            return "()"

    def _get_type_hints(self, obj: Any) -> dict[str, str]:
        """Get type hints as strings."""
        try:
            hints = get_type_hints(obj)
            return {k: str(v) for k, v in hints.items()}
        except Exception:
            return {}

    def write_markdown(self, docs: list[APIDoc], output_dir: Path) -> None:
        """Write API docs as markdown files."""
        output_dir.mkdir(parents=True, exist_ok=True)

        for doc in docs:
            filename = f"{doc.name.lower()}.md"
            content = self._format_markdown(doc)
            (output_dir / filename).write_text(content)

    def _format_markdown(self, doc: APIDoc) -> str:
        """Format API doc as markdown."""
        lines = [
            f"# {doc.name}",
            "",
            f"```python",
            doc.signature,
            "```",
            "",
        ]

        if doc.docstring.summary:
            lines.extend([doc.docstring.summary, ""])

        if doc.docstring.description:
            lines.extend([doc.docstring.description, ""])

        if doc.docstring.args:
            lines.append("## Parameters")
            lines.append("")
            for arg, desc in doc.docstring.args.items():
                lines.append(f"- **{arg}**: {desc}")
            lines.append("")

        if doc.docstring.returns:
            lines.extend([
                "## Returns",
                "",
                doc.docstring.returns,
                "",
            ])

        if doc.members:
            lines.append("## Members")
            lines.append("")
            for member in doc.members:
                lines.append(f"### {member.name}")
                lines.append("")
                lines.append(f"```python")
                lines.append(f"{member.signature}")
                lines.append("```")
                lines.append("")
                if member.docstring.summary:
                    lines.append(member.docstring.summary)
                    lines.append("")

        return "\n".join(lines)


class DocstringValidator:
    """Validates docstring coverage and quality.

    Example:
        validator = DocstringValidator()
        report = validator.validate_module("maid_engine.core.engine")
        print(f"Coverage: {report.coverage:.1%}")
        for issue in report.issues:
            print(f"  {issue}")
    """

    @dataclass
    class ValidationReport:
        """Validation report."""
        total_items: int
        documented_items: int
        issues: list[str]

        @property
        def coverage(self) -> float:
            if self.total_items == 0:
                return 1.0
            return self.documented_items / self.total_items

    def validate_module(self, module_name: str) -> ValidationReport:
        """Validate docstrings in a module."""
        import importlib

        module = importlib.import_module(module_name)
        total = 0
        documented = 0
        issues = []

        for name, obj in inspect.getmembers(module):
            if name.startswith("_"):
                continue

            if inspect.isclass(obj):
                class_total, class_doc, class_issues = self._validate_class(obj)
                total += class_total
                documented += class_doc
                issues.extend(class_issues)
            elif inspect.isfunction(obj):
                total += 1
                if obj.__doc__:
                    documented += 1
                else:
                    issues.append(f"Function '{name}' missing docstring")

        return self.ValidationReport(total, documented, issues)

    def _validate_class(self, cls: type) -> tuple[int, int, list[str]]:
        """Validate a class and its members."""
        total = 1
        documented = 1 if cls.__doc__ else 0
        issues = []

        if not cls.__doc__:
            issues.append(f"Class '{cls.__name__}' missing docstring")

        for name, method in inspect.getmembers(cls, inspect.isfunction):
            if name.startswith("_") and name != "__init__":
                continue
            total += 1
            if method.__doc__:
                documented += 1
            else:
                issues.append(f"Method '{cls.__name__}.{name}' missing docstring")

        return total, documented, issues

3.4.3 MkDocs Configuration

# mkdocs.yml

site_name: MAID Documentation
site_description: Multi AI Dungeon - Modern MUD Engine
site_url: https://dventuring.com

repo_name: maid/MAID
repo_url: https://github.com/maid/MAID

theme:
  name: material
  palette:
    - scheme: default
      primary: indigo
      accent: indigo
      toggle:
        icon: material/brightness-7
        name: Switch to dark mode
    - scheme: slate
      primary: indigo
      accent: indigo
      toggle:
        icon: material/brightness-4
        name: Switch to light mode
  features:
    - navigation.instant
    - navigation.tracking
    - navigation.tabs
    - navigation.sections
    - navigation.expand
    - navigation.indexes
    - search.suggest
    - search.highlight
    - content.code.copy
    - content.code.annotate

plugins:
  - search
  - mkdocstrings:
      handlers:
        python:
          options:
            docstring_style: google
            show_source: true
            show_signature_annotations: true
  - gen-files:
      scripts:
        - docs/gen_ref_pages.py
  - literate-nav:
      nav_file: SUMMARY.md
  - section-index

markdown_extensions:
  - pymdownx.highlight:
      anchor_linenums: true
  - pymdownx.superfences:
      custom_fences:
        - name: mermaid
          class: mermaid
          format: !!python/name:pymdownx.superfences.fence_code_format
  - pymdownx.tabbed:
      alternate_style: true
  - admonition
  - pymdownx.details
  - attr_list
  - md_in_html
  - toc:
      permalink: true

nav:
  - Home: index.md
  - Getting Started:
    - Installation: getting-started/installation.md
    - Quickstart: getting-started/quickstart.md
    - First Plugin: getting-started/first-plugin.md
    - Concepts: getting-started/concepts.md
  - Guides:
    - Content Packs: guides/content-packs/
    - ECS: guides/ecs/
    - Events: guides/events/
    - Commands: guides/commands/
    - Hot Reload: guides/hot-reload/
    - Advanced: guides/advanced/
  - Tutorials:
    - Combat System: tutorials/combat-system/
    - Magic System: tutorials/magic-system/
    - Complete Game: tutorials/complete-game/
  - Reference:
    - API: reference/api/
    - CLI: cli_reference.md
    - Configuration: reference/configuration.md
    - Events: reference/events.md
    - Components: reference/components.md
  - Migration:
    - Changelog: migration/changelog.md
  - Contributing:
    - Guidelines: contributing/guidelines.md

extra:
  version:
    provider: mike
  social:
    - icon: fontawesome/brands/github
      link: https://github.com/maid/MAID
    - icon: fontawesome/brands/discord
      link: https://discord.gg/MAID

3.4.4 Auto-Generation Script

#!/usr/bin/env python3
"""Generate API reference pages for mkdocs."""

from pathlib import Path
import mkdocs_gen_files

# Packages to document
PACKAGES = [
    "maid_engine",
    "maid_stdlib",
    "maid_classic_rpg",
]

nav = mkdocs_gen_files.Nav()

for package in PACKAGES:
    package_path = Path("packages") / package.replace("_", "-") / "src" / package

    for path in sorted(package_path.rglob("*.py")):
        if path.name.startswith("_"):
            continue

        module_path = path.relative_to(package_path.parent).with_suffix("")
        doc_path = path.relative_to(package_path.parent).with_suffix(".md")
        full_doc_path = Path("reference/api") / doc_path

        parts = tuple(module_path.parts)

        if parts[-1] == "__init__":
            parts = parts[:-1]
            doc_path = doc_path.with_name("index.md")
            full_doc_path = full_doc_path.with_name("index.md")

        nav[parts] = doc_path.as_posix()

        with mkdocs_gen_files.open(full_doc_path, "w") as fd:
            ident = ".".join(parts)
            fd.write(f"::: {ident}")

        mkdocs_gen_files.set_edit_path(full_doc_path, path)

with mkdocs_gen_files.open("reference/api/SUMMARY.md", "w") as nav_file:
    nav_file.writelines(nav.build_literate_nav())

3.5 Configuration

# Documentation configuration
MAID_DOCS__OUTPUT_DIR=docs/build
MAID_DOCS__SERVE_PORT=8000
MAID_DOCS__AUTO_RELOAD=true

3.6 Dependencies

Library Version Purpose
mkdocs >=1.5.0 Static site generator
mkdocs-material >=9.4.0 Material theme
mkdocstrings[python] >=0.24.0 API documentation
mkdocs-gen-files >=0.5.0 Auto-generate pages
mkdocs-literate-nav >=0.6.0 Navigation from files
mike >=2.0.0 Version management

3.7 Implementation Tasks

Phase 1: Documentation Site Setup (P0)

  • [ ] Create docs/ directory structure
  • [ ] Create mkdocs.yml configuration
  • [ ] Write docs/index.md landing page
  • [ ] Write getting started guides
  • [ ] Set up GitHub Pages deployment
  • [ ] Add maid docs serve CLI command
  • [ ] Add maid docs build CLI command

Phase 2: Core Documentation Content (P0)

  • [ ] Write content pack creation guide
  • [ ] Write ECS architecture guide
  • [ ] Write event system guide
  • [ ] Write command system guide
  • [ ] Write configuration reference
  • [ ] Document all built-in events
  • [ ] Document all built-in components

Phase 3: API Documentation (P0)

  • [ ] Implement APIDocGenerator class
  • [ ] Create gen_ref_pages.py script
  • [ ] Configure mkdocstrings
  • [ ] Generate API docs for maid_engine
  • [ ] Generate API docs for maid_stdlib
  • [ ] Generate API docs for maid_classic_rpg
  • [ ] Validate 90%+ docstring coverage

Phase 4: Tutorial Content (P1)

  • [ ] Write combat system tutorial (5 parts)
  • [ ] Write magic system tutorial
  • [ ] Write complete game tutorial
  • [ ] Create example code repositories

Phase 5: Plugin Documentation Templates (P1)

  • [ ] Add documentation templates to scaffolder
  • [ ] Create plugin documentation standard
  • [ ] Implement doc validation in CI
  • [ ] Add documentation to registry metadata

Phase 6: Advanced Documentation (P2)

  • [ ] Add version selector with mike
  • [ ] Create migration guides
  • [ ] Generate changelog from git
  • [ ] Add search analytics (de-scoped: requires external service like Google Analytics or Algolia)
  • [ ] Add documentation feedback

3.8 Testing Requirements

# tests/docs/test_doc_generator.py

class TestAPIDocGenerator:
    def test_generates_class_docs(self):
        """Test that class documentation is generated."""
        generator = APIDocGenerator()
        docs = generator.generate_module("maid_engine.core.engine")

        game_engine_doc = next(d for d in docs if d.name == "GameEngine")
        assert game_engine_doc.kind == "class"
        assert game_engine_doc.members  # Has methods

    def test_generates_function_docs(self):
        """Test that function documentation is generated."""
        # ...


class TestDocstringValidator:
    def test_validates_coverage(self):
        """Test that coverage is calculated correctly."""
        validator = DocstringValidator()
        report = validator.validate_module("maid_engine.core.engine")

        assert 0 <= report.coverage <= 1
        assert report.total_items > 0

3.9 Acceptance Criteria

ID Criterion Verification Method
AC-3.1 Documentation site builds without errors CI test
AC-3.2 Search returns relevant results Manual test
AC-3.3 API reference covers all public APIs Coverage check
AC-3.4 All guides render correctly Visual inspection
AC-3.5 Code examples in docs are runnable Doc test
AC-3.6 Documentation deploys on every release CI check
AC-3.7 90%+ docstring coverage on core modules Validator check

Feature 4: Community Contribution Guidelines

4.1 Feature Overview

What it does:
Establishes a complete framework for community contributions to the MAID ecosystem, including contribution guidelines, code review processes, plugin quality standards, and governance structures.

Why it's needed: - Evennia's 40+ contribs were built by a community over 15 years - MAID needs to bootstrap community contributions from day one - Clear guidelines reduce friction for new contributors - Quality standards ensure plugin reliability - Governance prevents fragmentation and maintains coherence

4.2 User Stories

US-4.1: First Contribution

As a developer who wants to contribute, I want clear guidelines on how to submit my first contribution so that my PR isn't rejected for procedural reasons.

US-4.2: Plugin Standards

As a plugin author, I want to understand what quality standards my plugin must meet so that it can be accepted into the official registry.

US-4.3: Code Review

As a contributor, I want timely and constructive code reviews so that I can improve my contribution and get it merged.

US-4.4: Feature Discussion

As a community member, I want a place to discuss and propose new features so that my ideas are heard and considered.

US-4.5: Recognition

As a contributor, I want recognition for my contributions so that I feel valued and motivated to continue.

4.3 Technical Requirements

4.3.1 Contribution Process Requirements

ID Requirement Priority
CTB-001 Repository SHALL have CONTRIBUTING.md P0
CTB-002 Repository SHALL have CODE_OF_CONDUCT.md P0
CTB-003 Repository SHALL have pull request template P0
CTB-004 Repository SHALL have issue templates P0
CTB-005 Repository SHALL have automated CI checks P0
CTB-006 System SHALL have defined review SLA P1
CTB-007 System SHALL have contributor recognition P1

4.3.2 Plugin Quality Requirements

ID Requirement Priority
QLT-001 Plugins SHALL pass protocol compliance tests P0
QLT-002 Plugins SHALL have >80% test coverage P0
QLT-003 Plugins SHALL have documentation P0
QLT-004 Plugins SHALL follow code style guidelines P0
QLT-005 Plugins SHALL declare MAID version compatibility P0
QLT-006 Plugins SHALL have type annotations P1
QLT-007 Plugins SHOULD have example usage P1

4.3.3 Governance Requirements

ID Requirement Priority
GOV-001 Project SHALL have clear decision-making process P1
GOV-002 Project SHALL have maintainer guidelines P1
GOV-003 Project SHALL have RFC process for major changes P1
GOV-004 Project SHALL have security policy P0

4.4 API/Interface Design

4.4.1 CONTRIBUTING.md Template

# Contributing to MAID

Thank you for your interest in contributing to MAID! This document provides
guidelines and information for contributors.

## Table of Contents

1. [Code of Conduct](#code-of-conduct)
2. [Getting Started](#getting-started)
3. [Development Setup](#development-setup)
4. [Making Changes](#making-changes)
5. [Pull Request Process](#pull-request-process)
6. [Plugin Contributions](#plugin-contributions)
7. [Documentation](#documentation)
8. [Getting Help](#getting-help)

## Code of Conduct

This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md).
By participating, you are expected to uphold this code.

## Getting Started

### Types of Contributions

- **Bug fixes**: Fix issues reported in GitHub Issues
- **Features**: Implement features discussed and approved in Issues/Discussions
- **Documentation**: Improve guides, tutorials, and API docs
- **Plugins**: Create new content packs for the plugin registry
- **Tests**: Improve test coverage and reliability
- **Examples**: Add example code and tutorials

### First-Time Contributors

Look for issues labeled `good-first-issue` or `help-wanted`. These are
specifically selected for new contributors.

## Development Setup

```bash
# Clone the repository
git clone https://github.com/maid/MAID.git
cd MAID

# Install uv if you haven't already
curl -LsSf https://astral.sh/uv/install.sh | sh

# Install dependencies
uv sync --all-extras

# Run tests to verify setup
uv run pytest packages/

# Run linting
uv run ruff check packages/

# Run type checking
uv run mypy packages/

Making Changes

Branch Naming

  • feature/short-description - New features
  • fix/issue-number-short-description - Bug fixes
  • docs/short-description - Documentation changes
  • refactor/short-description - Code refactoring

Commit Messages

Follow Conventional Commits:

type(scope): description

[optional body]

[optional footer]

Types: feat, fix, docs, style, refactor, test, chore

Examples:

feat(hot-reload): add file watcher for auto-reload
fix(ecs): prevent system registration during tick
docs(guides): add hot reload development guide

Code Style

  • Follow PEP 8 with modifications defined in pyproject.toml
  • Use type hints for all public APIs
  • Write docstrings in Google style
  • Maximum line length: 88 characters (Black default)
  • Use ruff for linting and mypy for type checking

Pull Request Process

Before Submitting

  1. [ ] Create an issue first for non-trivial changes
  2. [ ] Fork and create a feature branch
  3. [ ] Write/update tests for your changes
  4. [ ] Ensure all tests pass: uv run pytest
  5. [ ] Ensure linting passes: uv run ruff check
  6. [ ] Ensure types check: uv run mypy
  7. [ ] Update documentation if needed
  8. [ ] Add entry to CHANGELOG.md (if applicable)

PR Requirements

  • Clear title following conventional commits
  • Description of changes and motivation
  • Link to related issue(s)
  • Tests for new functionality
  • Documentation for new features
  • No decrease in test coverage

Review Process

  1. Automated checks - CI must pass
  2. Code review - At least one maintainer approval
  3. Documentation review - For user-facing changes
  4. Merge - Maintainer merges using squash

Review SLA

  • Initial response: Within 3 business days
  • Follow-up reviews: Within 2 business days
  • If no response, ping @maintainers

Plugin Contributions

Official Plugins (maid-contrib)

To contribute an official plugin:

  1. Create plugin using maid plugin new
  2. Ensure all quality requirements are met
  3. Submit PR to the maid-contrib repository
  4. Follow the plugin review checklist

Plugin Quality Checklist

  • [ ] Passes maid plugin test
  • [ ] Has >80% test coverage
  • [ ] Has complete documentation
  • [ ] Declares MAID version compatibility
  • [ ] Uses type hints throughout
  • [ ] Follows code style guidelines
  • [ ] Has example usage
  • [ ] No security vulnerabilities

Registry Plugins

Third-party plugins can be submitted to the registry:

  1. Publish to PyPI
  2. Register at https://registry.dventuring.com
  3. Submit for verification (optional)

Documentation

Building Docs

# Serve docs locally
uv run maid docs serve

# Build docs
uv run maid docs build

Documentation Style

  • Use clear, concise language
  • Include code examples
  • Link to related documentation
  • Keep examples runnable

Getting Help

  • Discord: https://discord.gg/MAID
  • Discussions: https://github.com/maid/MAID/discussions
  • Issues: https://github.com/maid/MAID/issues

Recognition

Contributors are recognized in:

  • AUTHORS.md file
  • Release notes
  • Documentation
  • Annual contributor spotlight
    #### 4.4.2 Pull Request Template
    
    ```markdown
    <!-- .github/pull_request_template.md -->
    
    ## Description
    
    <!-- Describe your changes in detail -->
    
    ## Motivation
    
    <!-- Why is this change needed? Link to issue if applicable -->
    
    Fixes #
    
    ## Type of Change
    
    - [ ] Bug fix (non-breaking change that fixes an issue)
    - [ ] New feature (non-breaking change that adds functionality)
    - [ ] Breaking change (fix or feature that would cause existing functionality to change)
    - [ ] Documentation update
    - [ ] Refactoring (no functional changes)
    - [ ] Test improvement
    
    ## Checklist
    
    - [ ] I have read the [CONTRIBUTING](../../CONTRIBUTING.md) guidelines
    - [ ] My code follows the code style of this project
    - [ ] I have added tests that prove my fix/feature works
    - [ ] All new and existing tests pass
    - [ ] I have updated the documentation accordingly
    - [ ] I have added an entry to CHANGELOG.md (if applicable)
    
    ## Testing
    
    <!-- Describe how you tested your changes -->
    
    ## Screenshots (if applicable)
    
    <!-- Add screenshots to help explain your changes -->
    
    ## Additional Notes
    
    <!-- Any additional information that reviewers should know -->
    

4.4.3 Issue Templates

# .github/ISSUE_TEMPLATE/bug_report.yml

name: Bug Report
description: Report a bug in MAID
title: "[Bug]: "
labels: ["bug", "triage"]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for reporting a bug! Please fill out the form below.

  - type: textarea
    id: description
    attributes:
      label: Bug Description
      description: A clear and concise description of the bug
      placeholder: What happened?
    validations:
      required: true

  - type: textarea
    id: reproduction
    attributes:
      label: Steps to Reproduce
      description: Steps to reproduce the behavior
      placeholder: |
        1. Create a content pack with...
        2. Load it using...
        3. Run command...
        4. See error
    validations:
      required: true

  - type: textarea
    id: expected
    attributes:
      label: Expected Behavior
      description: What did you expect to happen?
    validations:
      required: true

  - type: textarea
    id: actual
    attributes:
      label: Actual Behavior
      description: What actually happened?
    validations:
      required: true

  - type: input
    id: version
    attributes:
      label: MAID Version
      description: Output of `maid --version`
      placeholder: "0.1.0"
    validations:
      required: true

  - type: input
    id: python
    attributes:
      label: Python Version
      description: Output of `python --version`
      placeholder: "3.12.0"
    validations:
      required: true

  - type: dropdown
    id: os
    attributes:
      label: Operating System
      options:
        - Linux
        - macOS
        - Windows
        - Other
    validations:
      required: true

  - type: textarea
    id: logs
    attributes:
      label: Relevant Log Output
      description: Please copy and paste any relevant log output
      render: shell

  - type: textarea
    id: additional
    attributes:
      label: Additional Context
      description: Any other context about the problem
# .github/ISSUE_TEMPLATE/feature_request.yml

name: Feature Request
description: Suggest a new feature for MAID
title: "[Feature]: "
labels: ["enhancement", "triage"]
body:
  - type: markdown
    attributes:
      value: |
        Thanks for suggesting a feature! Please fill out the form below.

  - type: textarea
    id: problem
    attributes:
      label: Problem Statement
      description: What problem does this feature solve?
      placeholder: I'm always frustrated when...
    validations:
      required: true

  - type: textarea
    id: solution
    attributes:
      label: Proposed Solution
      description: Describe the solution you'd like
    validations:
      required: true

  - type: textarea
    id: alternatives
    attributes:
      label: Alternatives Considered
      description: What alternatives have you considered?

  - type: dropdown
    id: scope
    attributes:
      label: Scope
      description: What part of MAID does this affect?
      options:
        - Engine Core
        - Standard Library
        - Classic RPG Pack
        - Plugin System
        - CLI
        - Documentation
        - Other
    validations:
      required: true

  - type: checkboxes
    id: contribution
    attributes:
      label: Contribution
      description: Would you like to contribute this feature?
      options:
        - label: I would like to implement this feature

4.4.4 Plugin Quality Checker

"""Plugin quality checker for contribution validation."""

from __future__ import annotations

import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from maid_engine.plugins.protocol import ContentPack


@dataclass
class QualityCheckResult:
    """Result of a quality check."""
    passed: bool
    check_name: str
    message: str
    details: list[str] = field(default_factory=list)


@dataclass
class QualityReport:
    """Complete quality report for a plugin."""
    plugin_name: str
    passed: bool
    checks: list[QualityCheckResult]
    coverage: float = 0.0

    @property
    def summary(self) -> str:
        passed = sum(1 for c in self.checks if c.passed)
        total = len(self.checks)
        return f"{passed}/{total} checks passed"


class PluginQualityChecker:
    """Checks plugin quality against contribution standards.

    Example:
        checker = PluginQualityChecker()
        report = await checker.check_plugin(Path("my-plugin"))

        if report.passed:
            print("Plugin meets all quality standards!")
        else:
            for check in report.checks:
                if not check.passed:
                    print(f"FAILED: {check.check_name}")
                    print(f"  {check.message}")
    """

    MINIMUM_COVERAGE = 0.80

    async def check_plugin(self, plugin_path: Path) -> QualityReport:
        """Run all quality checks on a plugin.

        Args:
            plugin_path: Path to the plugin directory

        Returns:
            QualityReport with all check results
        """
        checks = [
            self._check_manifest(plugin_path),
            self._check_protocol_compliance(plugin_path),
            await self._check_tests(plugin_path),
            await self._check_coverage(plugin_path),
            self._check_documentation(plugin_path),
            await self._check_linting(plugin_path),
            await self._check_type_hints(plugin_path),
            self._check_version_compatibility(plugin_path),
        ]

        coverage = await self._get_coverage(plugin_path)

        return QualityReport(
            plugin_name=plugin_path.name,
            passed=all(c.passed for c in checks),
            checks=checks,
            coverage=coverage,
        )

    def _check_manifest(self, plugin_path: Path) -> QualityCheckResult:
        """Check that manifest exists and is valid."""
        manifest_path = plugin_path / "pyproject.toml"

        if not manifest_path.exists():
            return QualityCheckResult(
                passed=False,
                check_name="manifest",
                message="pyproject.toml not found",
            )

        try:
            import tomllib
            with open(manifest_path, "rb") as f:
                data = tomllib.load(f)

            # Check required fields
            required = ["name", "version", "description"]
            project = data.get("project", {})
            missing = [f for f in required if f not in project]

            if missing:
                return QualityCheckResult(
                    passed=False,
                    check_name="manifest",
                    message=f"Missing required fields: {missing}",
                )

            # Check entry point
            eps = data.get("project", {}).get("entry-points", {})
            if "maid.content_packs" not in eps:
                return QualityCheckResult(
                    passed=False,
                    check_name="manifest",
                    message="Missing maid.content_packs entry point",
                )

            return QualityCheckResult(
                passed=True,
                check_name="manifest",
                message="Manifest is valid",
            )

        except Exception as e:
            return QualityCheckResult(
                passed=False,
                check_name="manifest",
                message=f"Error parsing manifest: {e}",
            )

    def _check_protocol_compliance(self, plugin_path: Path) -> QualityCheckResult:
        """Check that plugin implements ContentPack protocol."""
        # Run protocol compliance tests
        try:
            from maid_engine.plugins.testing import ContentPackTestCase
            # ... run compliance tests
            return QualityCheckResult(
                passed=True,
                check_name="protocol_compliance",
                message="Plugin implements ContentPack protocol",
            )
        except Exception as e:
            return QualityCheckResult(
                passed=False,
                check_name="protocol_compliance",
                message=f"Protocol compliance failed: {e}",
            )

    async def _check_tests(self, plugin_path: Path) -> QualityCheckResult:
        """Check that tests exist and pass."""
        tests_dir = plugin_path / "tests"

        if not tests_dir.exists():
            return QualityCheckResult(
                passed=False,
                check_name="tests",
                message="No tests directory found",
            )

        # Count test files
        test_files = list(tests_dir.glob("test_*.py"))
        if not test_files:
            return QualityCheckResult(
                passed=False,
                check_name="tests",
                message="No test files found",
            )

        # Run tests
        result = subprocess.run(
            ["pytest", str(tests_dir), "-q"],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            return QualityCheckResult(
                passed=False,
                check_name="tests",
                message="Tests failed",
                details=result.stdout.split("\n"),
            )

        return QualityCheckResult(
            passed=True,
            check_name="tests",
            message=f"All tests pass ({len(test_files)} test files)",
        )

    async def _check_coverage(self, plugin_path: Path) -> QualityCheckResult:
        """Check test coverage meets minimum."""
        coverage = await self._get_coverage(plugin_path)

        if coverage < self.MINIMUM_COVERAGE:
            return QualityCheckResult(
                passed=False,
                check_name="coverage",
                message=f"Coverage {coverage:.1%} below minimum {self.MINIMUM_COVERAGE:.1%}",
            )

        return QualityCheckResult(
            passed=True,
            check_name="coverage",
            message=f"Coverage {coverage:.1%} meets minimum",
        )

    async def _get_coverage(self, plugin_path: Path) -> float:
        """Get test coverage percentage."""
        result = subprocess.run(
            ["pytest", "--cov", str(plugin_path / "src"), "--cov-report=json", "-q"],
            capture_output=True,
            text=True,
            cwd=plugin_path,
        )

        if result.returncode != 0:
            return 0.0

        import json
        try:
            cov_file = plugin_path / "coverage.json"
            if cov_file.exists():
                data = json.loads(cov_file.read_text())
                return data.get("totals", {}).get("percent_covered", 0) / 100
        except Exception:
            pass

        return 0.0

    def _check_documentation(self, plugin_path: Path) -> QualityCheckResult:
        """Check that documentation exists."""
        readme = plugin_path / "README.md"
        docs_dir = plugin_path / "docs"

        has_readme = readme.exists()
        has_docs = docs_dir.exists() and any(docs_dir.glob("*.md"))

        if not has_readme:
            return QualityCheckResult(
                passed=False,
                check_name="documentation",
                message="README.md not found",
            )

        # Check README has required sections
        content = readme.read_text().lower()
        required_sections = ["installation", "usage"]
        missing = [s for s in required_sections if s not in content]

        if missing:
            return QualityCheckResult(
                passed=False,
                check_name="documentation",
                message=f"README missing sections: {missing}",
            )

        return QualityCheckResult(
            passed=True,
            check_name="documentation",
            message="Documentation present and complete",
        )

    async def _check_linting(self, plugin_path: Path) -> QualityCheckResult:
        """Check code passes linting."""
        result = subprocess.run(
            ["ruff", "check", str(plugin_path / "src")],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            return QualityCheckResult(
                passed=False,
                check_name="linting",
                message="Linting errors found",
                details=result.stdout.split("\n")[:10],  # First 10 errors
            )

        return QualityCheckResult(
            passed=True,
            check_name="linting",
            message="Code passes linting",
        )

    async def _check_type_hints(self, plugin_path: Path) -> QualityCheckResult:
        """Check type hints with mypy."""
        result = subprocess.run(
            ["mypy", str(plugin_path / "src")],
            capture_output=True,
            text=True,
        )

        if result.returncode != 0:
            return QualityCheckResult(
                passed=False,
                check_name="type_hints",
                message="Type checking errors found",
                details=result.stdout.split("\n")[:10],
            )

        return QualityCheckResult(
            passed=True,
            check_name="type_hints",
            message="Type checking passes",
        )

    def _check_version_compatibility(self, plugin_path: Path) -> QualityCheckResult:
        """Check MAID version compatibility is declared."""
        manifest_path = plugin_path / "pyproject.toml"

        try:
            import tomllib
            with open(manifest_path, "rb") as f:
                data = tomllib.load(f)

            deps = data.get("project", {}).get("dependencies", [])
            maid_dep = next((d for d in deps if "maid-engine" in d), None)

            if not maid_dep:
                return QualityCheckResult(
                    passed=False,
                    check_name="version_compatibility",
                    message="maid-engine dependency not declared",
                )

            # Check for version constraint
            if ">" not in maid_dep and "<" not in maid_dep and "=" not in maid_dep:
                return QualityCheckResult(
                    passed=False,
                    check_name="version_compatibility",
                    message="maid-engine version constraint not specified",
                )

            return QualityCheckResult(
                passed=True,
                check_name="version_compatibility",
                message=f"MAID version compatibility declared: {maid_dep}",
            )

        except Exception as e:
            return QualityCheckResult(
                passed=False,
                check_name="version_compatibility",
                message=f"Error checking compatibility: {e}",
            )

4.5 Configuration

# Contribution configuration
MAID_CONTRIB__MIN_COVERAGE=0.80
MAID_CONTRIB__REQUIRE_TYPE_HINTS=true
MAID_CONTRIB__REVIEW_SLA_DAYS=3

4.6 Dependencies

No additional dependencies required for this feature.

4.7 Implementation Tasks

Phase 1: Repository Setup (P0)

  • [ ] Create CONTRIBUTING.md
  • [ ] Create CODE_OF_CONDUCT.md (Contributor Covenant)
  • [ ] Create pull request template
  • [ ] Create bug report issue template
  • [ ] Create feature request issue template
  • [ ] Create plugin submission issue template
  • [ ] Add branch protection rules
  • [ ] Configure required CI checks

Phase 2: Quality Checker (P0)

  • [ ] Implement PluginQualityChecker class
  • [ ] Implement manifest validation
  • [ ] Implement protocol compliance check
  • [ ] Implement test validation
  • [ ] Implement coverage check
  • [ ] Implement documentation check
  • [ ] Implement linting check
  • [ ] Implement type hint check
  • [ ] Add maid plugin check CLI command
  • [ ] Write tests for quality checker

Phase 3: CI Integration (P0)

  • [ ] Create CI workflow for PRs
  • [ ] Add automated quality checks
  • [ ] Add test coverage reporting
  • [ ] Add documentation build check
  • [ ] Configure Dependabot
  • [ ] Add security scanning

Phase 4: Governance (P1)

  • [ ] Create MAINTAINERS.md
  • [ ] Create RFC process documentation
  • [ ] Create SECURITY.md
  • [ ] Define release process
  • [ ] Create AUTHORS.md with contributor list
  • [ ] Set up Discord community

Phase 5: Plugin Submission (P1)

  • [ ] Create maid-contrib repository
  • [ ] Define plugin review process
  • [ ] Create plugin submission checklist
  • [ ] Set up plugin CI pipeline
  • [ ] Document registry submission process

4.8 Testing Requirements

# tests/plugins/test_quality_checker.py

class TestPluginQualityChecker:
    async def test_validates_manifest(self, tmp_path):
        """Test manifest validation."""
        # Create invalid manifest
        (tmp_path / "pyproject.toml").write_text("[project]")

        checker = PluginQualityChecker()
        result = checker._check_manifest(tmp_path)

        assert not result.passed
        assert "Missing required fields" in result.message

    async def test_validates_test_coverage(self, tmp_path):
        """Test coverage validation."""
        # ...

    async def test_full_quality_check(self, valid_plugin):
        """Test complete quality check on valid plugin."""
        checker = PluginQualityChecker()
        report = await checker.check_plugin(valid_plugin)

        assert report.passed
        assert report.coverage >= 0.80

4.9 Acceptance Criteria

ID Criterion Verification Method
AC-4.1 CONTRIBUTING.md exists and is comprehensive Review
AC-4.2 All issue templates work correctly Manual test
AC-4.3 PR template captures necessary information Review
AC-4.4 Quality checker validates all requirements Unit test
AC-4.5 CI runs on all PRs CI check
AC-4.6 First contribution guide is clear User testing
AC-4.7 Plugin submission process is documented Review

Appendix A: Migration Strategies

A.1 Component Schema Migration

When component schemas change between versions, the hot reload system needs to migrate existing entity data. This appendix details the migration strategy.

A.1.1 Migration Definition

@dataclass
class ComponentMigration:
    """Describes a component schema migration."""
    component_type: str           # e.g., "HealthComponent"
    from_version: str             # e.g., "1.0.0"
    to_version: str               # e.g., "2.0.0"
    migration_fn: Callable[[dict], dict]  # Transform function


# Example migration
def migrate_health_v1_to_v2(data: dict) -> dict:
    """Migrate HealthComponent from v1 to v2.

    Changes:
    - Renamed 'hp' to 'current'
    - Renamed 'max_hp' to 'maximum'
    - Added 'regeneration_rate' with default 0.0
    """
    return {
        "current": data.get("hp", data.get("current", 100)),
        "maximum": data.get("max_hp", data.get("maximum", 100)),
        "regeneration_rate": data.get("regeneration_rate", 0.0),
    }

A.1.2 Migration Chain

For multi-version jumps, migrations are chained:

# v1 -> v2 -> v3
migrations = [
    ComponentMigration("Health", "1.0", "2.0", migrate_v1_to_v2),
    ComponentMigration("Health", "2.0", "3.0", migrate_v2_to_v3),
]

# System automatically chains: v1 data -> v2 -> v3

A.1.3 Rollback Migration

Each migration should have a reverse migration for rollback:

@dataclass
class ReversibleMigration:
    forward: ComponentMigration
    reverse: ComponentMigration

A.2 System Replacement Strategy

When systems are hot-swapped, state must be preserved.

A.2.1 System State Protocol

class StatefulSystem(System, Protocol):
    """Protocol for systems that maintain state."""

    def capture_state(self) -> dict[str, Any]:
        """Capture current system state for migration."""
        ...

    def restore_state(self, state: dict[str, Any]) -> None:
        """Restore system state after reload."""
        ...


# Example implementation
class CombatSystem(System):
    def __init__(self, world: World) -> None:
        super().__init__(world)
        self._active_combats: dict[UUID, Combat] = {}

    def capture_state(self) -> dict[str, Any]:
        return {
            "active_combats": {
                str(k): v.to_dict() for k, v in self._active_combats.items()
            }
        }

    def restore_state(self, state: dict[str, Any]) -> None:
        for combat_id, combat_data in state.get("active_combats", {}).items():
            self._active_combats[UUID(combat_id)] = Combat.from_dict(combat_data)

A.2.2 State Transfer Flow

1. Old system.capture_state() -> state dict
2. Old system.shutdown()
3. Unregister old system
4. Register new system
5. New system.startup()
6. New system.restore_state(state dict)

A.3 Event Handler Migration

Event handlers from unloaded packs must be cleanly removed without losing events.

A.3.1 Handler Tracking

# EventBus enhancement
class EventBus:
    def __init__(self):
        self._handlers: dict[type[Event], list[HandlerEntry]] = {}
        self._handlers_by_pack: dict[str, set[UUID]] = {}  # NEW

    def subscribe(
        self,
        event_type: type[Event],
        handler: EventHandler,
        pack_name: str = "core",  # NEW
    ) -> UUID:
        handler_id = uuid4()
        # ... existing registration

        # Track by pack
        if pack_name not in self._handlers_by_pack:
            self._handlers_by_pack[pack_name] = set()
        self._handlers_by_pack[pack_name].add(handler_id)

        return handler_id

    def unsubscribe_pack(self, pack_name: str) -> int:
        """Unsubscribe all handlers from a pack."""
        handler_ids = self._handlers_by_pack.pop(pack_name, set())
        for handler_id in handler_ids:
            self.unsubscribe(handler_id)
        return len(handler_ids)

Appendix B: Risk Assessment

B.1 Hot Reload Risks

Risk Probability Impact Mitigation
Data loss during migration Medium High Snapshot before reload, rollback on failure
System state corruption Medium High StatefulSystem protocol, state validation
Orphaned event handlers Low Medium Handler tracking by pack
Tick processing race conditions Medium Medium Pause ticks during reload
Memory leaks from incomplete cleanup Medium Low Comprehensive cleanup, leak detection
Player disconnection Low High Non-blocking reload, connection preservation

B.2 Registry Risks

Risk Probability Impact Mitigation
Malicious plugins Medium High Plugin verification, code review
Version incompatibility High Medium Compatibility matrix, automated testing
Registry downtime Low Medium Local caching, fallback mirrors
Name squatting Medium Low Naming policy, moderation

B.3 Documentation Risks

Risk Probability Impact Mitigation
Documentation drift High Medium Docstring validation in CI
Broken code examples Medium Medium Doc testing, example CI
Incomplete coverage High Low Coverage reporting, reminders

Appendix C: Timeline Estimate

Phase 1: Foundation (Weeks 1-4)

  • Hot Reload core implementation
  • Scaffolding tool
  • Basic documentation site

Phase 2: Quality (Weeks 5-8)

  • Testing framework
  • Quality checker
  • CI integration
  • API documentation

Phase 3: Ecosystem (Weeks 9-12)

  • Registry client
  • File watcher
  • Tutorial content
  • Community setup

Phase 4: Polish (Weeks 13-16)

  • Registry server
  • Version migration tools
  • Complete documentation
  • Community outreach

Total Estimate: 16 weeks (4 months)


Appendix D: Success Metrics

D.1 Hot Reload

  • Reload latency < 500ms for typical pack
  • Zero data loss in 1000 reload cycles
  • Zero player disconnections during reload

D.2 Plugin Ecosystem

  • 10+ community plugins within 6 months
  • 40+ plugins within 2 years
  • <5 minutes to scaffold and test new plugin

D.3 Documentation

  • 90%+ docstring coverage
  • <30 seconds to find any concept via search
  • 5-minute quickstart completion rate >80%

D.4 Community

  • PR review SLA met >90% of time
  • First response to issues <24 hours
  • 50% of issues closed within 1 week


Appendix E: Glossary

Term Definition
Content Pack A pluggable module that provides game content (systems, commands, events)
Hot Reload Loading/unloading content packs at runtime without server restart
ECS Entity Component System architecture pattern
Component Migration Transforming component data when schema changes
Registry Central repository of available plugins
Scaffolding Auto-generating project structure from templates
Protocol Compliance Meeting the ContentPack interface requirements

Document History

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

End of Document