Skip to content

ADR-004: Content Pack Protocol (Structural Typing)

Status

Accepted

Date

2024-02-01

Context

MAID's content pack system allows third-party modules to register ECS systems, events, commands, and document schemas with the engine. The engine needs a way to define the interface that content packs must satisfy, while keeping the coupling between the engine and pack implementations as loose as possible.

Content packs are discovered through multiple mechanisms: explicit registration via engine.load_content_pack(), Python entry points (maid.content_packs group), and directory scanning with manifest.toml files. Discovery via entry points and directory scanning requires instantiation without constructor arguments.

Decision

Define the content pack interface as a Python Protocol with @runtime_checkable (ContentPack in packages/maid-engine/src/maid_engine/plugins/protocol.py) rather than an abstract base class. The protocol defines eight methods:

@runtime_checkable
class ContentPack(Protocol):
    @property
    def manifest(self) -> ContentPackManifest: ...
    def get_dependencies(self) -> list[str]: ...
    def get_systems(self, world: World) -> list[System]: ...
    def get_events(self) -> list[type[Event]]: ...
    def register_commands(self, registry: AnyCommandRegistry) -> None: ...
    def register_document_schemas(self, store: DocumentStore) -> None: ...
    async def on_load(self, engine: GameEngine) -> None: ...
    async def on_unload(self, engine: GameEngine) -> None: ...

A convenience base class BaseContentPack is provided alongside the protocol, offering default no-op implementations for all methods except manifest. Content packs can either implement the protocol directly (structural typing) or extend BaseContentPack (nominal typing).

The ContentPackLoader (packages/maid-engine/src/maid_engine/plugins/loader.py) discovers packs via importlib.metadata entry points, validates they satisfy the protocol at runtime using isinstance(pack, ContentPack), and resolves dependency ordering via topological sort.

Consequences

Positive

  • No forced inheritance: A content pack class does not need to inherit from any engine base class. It only needs to implement the required methods with matching signatures. This means content packs written for other frameworks could potentially be adapted with minimal wrapper code.
  • Duck typing alignment: Protocol-based design aligns with Python's duck typing philosophy. If a class has the right methods, it is a valid content pack.
  • Runtime validation: The @runtime_checkable decorator allows isinstance() checks during discovery, providing early error detection before the engine attempts to call pack methods.
  • Testing flexibility: Test doubles can implement just the methods needed for a specific test without extending BaseContentPack. The TestingContentPack in packages/maid-engine/src/maid_engine/plugins/testing.py demonstrates this.

Negative

  • Discoverability: New content pack authors cannot simply look at an ABC's abstract methods to see what they must implement. They need to read the Protocol definition or the BaseContentPack defaults. IDE support for protocol implementations is less mature than for ABCs.
  • No enforcement at definition time: A class missing a protocol method will not raise an error when defined, only when an isinstance check is performed or when the missing method is called. ABCs catch missing methods at class creation time.
  • Type signature drift: If a protocol method's signature changes, existing content packs will not get a compile-time error. This risk is mitigated by the runtime checks in ContentPackLoader.

Alternatives Considered

Abstract Base Class (ABC)

Using class ContentPack(ABC) with @abstractmethod decorators would enforce method implementation at class definition time. Rejected because it forces all content packs to inherit from the engine's base class, creating a hard coupling. Content packs from external libraries would need to subclass an engine type.

Class Registry with Decorators

A decorator-based approach (@register_content_pack) that registers classes in a global registry was considered. Rejected because global registries create import ordering issues and make testing difficult. The protocol approach is stateless.

Entry Points Only (No Protocol)

Relying solely on entry points with duck-typing and no formal interface was considered. Rejected because it provides no guidance to implementors about what methods they need, and error messages when a method is missing would be confusing (AttributeError instead of a clear protocol violation).