Skip to content

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)
  • skills and abilities
  • gold / bank_gold
  • current_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:

  1. 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.
  2. 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)).
  3. Systems and command handlers MUST NOT write game state to the legacy models. They mutate the Component.
  4. 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.
  5. 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 / .vitals need 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.py runs in the lint job of .github/workflows/ci.yml and 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 legacy Character model.
  • packages/maid-stdlib/src/maid_stdlib/components/ -- the canonical Component definitions.