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:
- Hot Loading/Reloading - Runtime content pack modification without server restart
- Plugin Ecosystem Infrastructure - Tools and registry to grow from 3 to 40+ plugins
- Extensive Plugin Documentation - Comprehensive documentation system for plugin authors
- 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¶
- Feature 1: Hot Loading/Reloading System
- Feature 2: Plugin Ecosystem Infrastructure
- Feature 3: Plugin Documentation System
- Feature 4: Community Contribution Guidelines
- Appendix A: Migration Strategies
- 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:
1.7 Implementation Tasks¶
Phase 1: Core Hot Reload (P0)¶
- [ ] Create
maid_engine/plugins/hot_reload.pymodule - [ ] Implement
HotReloadManagerclass - [ ] Implement
HotReloadResultandHotReloadContextdataclasses - [ ] Implement
HotReloadStateenum - [ ] Implement
HotReloadErrorexception hierarchy - [ ] Add
_hot_reload_pausesupport toGameEngine._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_reloadproperty toGameEngine - [ ] 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
ComponentMigrationdataclass - [ ] 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
PackFileWatcherclass - [ ] Add
watchfilesdependency 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
@packsadmin 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.j2template - [ ] Create
pack.py.j2template - [ ] Create
__init__.py.j2templates - [ ] Create test templates (
conftest.py.j2,test_pack.py.j2) - [ ] Implement
PluginConfigdataclass - [ ] Implement
PluginScaffolderclass - [ ] Implement
create_plugin_interactive()wizard - [ ] Add
maid plugin newCLI command - [ ] Add
maid plugin new --non-interactiveflag - [ ] Write tests for scaffolder
- [ ] Document scaffolding in user guide
Phase 2: Testing Framework (P0)¶
- [ ] Create
maid_engine/plugins/testing.pymodule - [ ] Implement
ContentPackTestCasebase class - [ ] Implement protocol compliance tests
- [ ] Create pytest fixtures (
mock_world,mock_engine) - [ ] Implement
CompatibilityTestSuite - [ ] Add
maid plugin testCLI command - [ ] Write tests for testing framework
- [ ] Document testing patterns
Phase 3: Registry Client (P1)¶
- [ ] Create
maid_engine/plugins/registry.pymodule - [ ] Implement
PluginMetadatadataclass - [ ] Implement
PluginRegistryClientclass - [ ] Implement search functionality
- [ ] Implement install/uninstall
- [ ] Add registry caching
- [ ] Add
maid plugin searchCLI command - [ ] Add
maid plugin installCLI command - [ ] Add
maid plugin registry-listCLI command - [ ] Add
maid plugin uninstallCLI 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.ymlconfiguration - [ ] Write
docs/index.mdlanding page - [ ] Write getting started guides
- [ ] Set up GitHub Pages deployment
- [ ] Add
maid docs serveCLI command - [ ] Add
maid docs buildCLI 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
APIDocGeneratorclass - [ ] Create
gen_ref_pages.pyscript - [ ] 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 featuresfix/issue-number-short-description- Bug fixesdocs/short-description- Documentation changesrefactor/short-description- Code refactoring
Commit Messages¶
Follow Conventional Commits:
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
rufffor linting andmypyfor type checking
Pull Request Process¶
Before Submitting¶
- [ ] Create an issue first for non-trivial changes
- [ ] Fork and create a feature branch
- [ ] Write/update tests for your changes
- [ ] Ensure all tests pass:
uv run pytest - [ ] Ensure linting passes:
uv run ruff check - [ ] Ensure types check:
uv run mypy - [ ] Update documentation if needed
- [ ] 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¶
- Automated checks - CI must pass
- Code review - At least one maintainer approval
- Documentation review - For user-facing changes
- 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:
- Create plugin using
maid plugin new - Ensure all quality requirements are met
- Submit PR to the maid-contrib repository
- 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:
- Publish to PyPI
- Register at https://registry.dventuring.com
- Submit for verification (optional)
Documentation¶
Building Docs¶
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
PluginQualityCheckerclass - [ ] 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 checkCLI 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:
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