ADR 0001: ECS is the Canonical Source of Runtime State¶
Status¶
Accepted
Date¶
2026-05-28
Context¶
MAID adopted an Entity Component System architecture from the start
(see ADR-001-ecs-architecture.md), but a
significant portion of game state is still carried on the legacy Pydantic
models inherited from earlier prototypes -- most prominently
maid_classic_rpg.models.entities.character.Character. That model still
defines (and many systems still read and write) runtime fields that should
belong to ECS Components:
inventory(carried item IDs)equipment(slot -> item ID)stats(strength, dexterity, ...)vitals(hp / mp / mv)skillsandabilitiesgold/bank_goldcurrent_weight
The result is a dual source of truth. The same logical fact (e.g. "this
character is wielding sword X") is represented in two places: on the
Character Pydantic model and on the corresponding ECS components
(InventoryComponent, EquipmentComponent, ...). Whichever side a system
happens to read or write determines what the game appears to remember.
PR #138 surfaced this concretely: the get command updated the legacy
character.inventory list, but wield queried the InventoryComponent.
The item could be picked up and then not be wielded, because the two
representations had silently diverged. The fix that PR shipped synchronises
the two stores on every mutation, but synchronisation is a treatment, not a
cure -- any new code that touches one side without the other reintroduces
the same class of bug.
Because MAID has zero deployed instances and zero production data, we can collapse this duality aggressively rather than maintaining a long forward-compatible migration.
Decision¶
All runtime game state lives in an ECS Component. The Pydantic
Character / Item / Room models are serialization adapters only.
Concretely:
- No new state fields may be added to
Character,Item,Room, or any other legacy Pydantic entity model. New state goes on a Component, full stop. - Systems and command handlers MUST NOT read game state from the legacy
models. They must query the appropriate Component on the Entity
(
world.entities.get_component(entity_id, FooComponent)). - Systems and command handlers MUST NOT write game state to the legacy models. They mutate the Component.
- The legacy models remain only as (a) a transient I/O shape for the
document store / GMCP / admin API and (b) construction-time defaults
feeding
register_components_for_character(). They are converted to Components at load and back to dicts at save. - The following legacy fields are explicitly flagged as DEPRECATED runtime state, slated for deletion once the migration is complete:
inventory, equipment, stats, vitals, skills, abilities,
gold, bank_gold, current_weight.
This is enforced by an AST-based ratchet script,
scripts/audit_no_legacy_state.py, which fails the build when any file
not already on the allowlist accesses one of these fields on a
character-like variable. The allowlist is expected to shrink, never grow.
Consequences¶
Positive¶
- One source of truth per fact eliminates the entire class of "wrong store was read/written" bugs (the get/wield divergence on PR #138, and the similar shapes lurking in combat, economy, magic, social, and crafting systems).
- ECS queries (
world.entities.with_components(...)) become the only way to find game state, so systems become uniformly composable. Adding a new capability (e.g. a faction system) is a new Component plus a new System, with no model-class surgery. - Hot reload, persistence batching, and the dirty-tracking save scheduler all already operate on Components -- once the duality is gone, they become the only path data takes, and we can trust their invariants (e.g. "every mutation is captured" becomes literally true).
- Content packs gain a stable, narrow integration surface: declare Components, register Systems, ignore Pydantic entity classes.
Negative / Costs¶
- The migration touches ~24 files and ~170+ callsites across the
classic-rpg pack and a few stdlib systems (mail, etc.). Each must be
audited and rewritten to use
EntityManager.get_component(...). - During the migration window the dual-store synchronisation code from PR #138 must be preserved; removing it prematurely would resurface the bug. It is deleted in the persistence-flip step (phase 4).
- Tests that constructed bare
Character(...)instances and asserted on.inventory/.vitalsneed to be rewritten to construct entities or to assert against the Component instead.
Neutral¶
- Pydantic validation (
validate_assignment=True,extra="forbid") moves from the entity model to the Component classes, which already use it. Net validation surface is unchanged.
Migration Plan¶
This ADR is the Phase 0 deliverable. Subsequent phases are scheduled as their own PRs.
| Phase | Deliverable | Status |
|---|---|---|
| 0 | ADR + ratchet guard (scripts/audit_no_legacy_state.py) + CI/pre-commit wiring + DEPRECATED docstrings on the legacy fields. No runtime behavior change. |
This PR. |
| 1 | Add any missing Components needed to back the legacy fields (notably a StatsComponent, VitalsComponent review, SkillsComponent, AbilitiesComponent, CurrencyComponent if not already present). Land them with unit tests; no callsite migration yet. |
Planned. |
| 2 | Migrate systems and command handlers package-by-package off the legacy fields and onto Components. Each migrated file is removed from scripts/legacy_state_allowlist.txt, which forces the ratchet to permanently lock in the migration. |
Planned (system-by-system). |
| 3 | Flip persistence: snapshot Components directly to the document store. The Pydantic models become pure read/write adapters used only at load/save boundaries. | Planned. |
| 4 | Delete the legacy runtime fields from Character / Item / Room. Remove the dual-store synchronisation code introduced by PR #138. The ratchet allowlist must be empty before this step. |
Planned. |
Enforcement¶
scripts/audit_no_legacy_state.pyruns in the lint job of.github/workflows/ci.ymland as a local pre-commit hook in.pre-commit-config.yaml.- The ratchet is additive-blocking: it forbids new violations in any file that is not already on the allowlist. Removing a file from the allowlist is a one-way ratchet step that should never be reverted.
References¶
- ADR-001-ecs-architecture.md -- original ECS adoption decision.
- PR #138 -- the get/wield dual-state bug whose fix synchronises the two stores and which motivated this ADR.
packages/maid-classic-rpg/src/maid_classic_rpg/models/entities/character.py-- the legacyCharactermodel.packages/maid-stdlib/src/maid_stdlib/components/-- the canonical Component definitions.