feat: Phase 3 simulation core — registry-driven world catalogs (D2) - #29
Conversation
The life-event and job catalogs were hardcoded module constants, so adding content meant editing catalog modules and content was global. Introduce EventRegistry and JobRegistry (mirroring StressorRegistry/PatternRegistry): - world/registry.py: singleton default() seeded from EVENTS / AVAILABLE_JOBS, plus register()/get()/all() and _reset() for tests. - EventEngine takes an optional events=EventRegistry, defaulting to the built-in one, so content is pluggable and per-engine; replaces direct EVENT_MAP/EVENTS use. - WorldState seeds its jobs from JobRegistry.default() (local import avoids the registry<->state cycle), so registered jobs flow into new worlds. - Tests: default seeding, register/get/all, replace-by-id, singleton, engine uses a custom registry, WorldState picks up a registered job. - Docs: new 'Custom World Content' extension section (EXTENDING.md + docs/extending) and an architecture extension-table row. First Phase 3 slice; unblocks D1 (feedback loops keyed on event/stressor maps).
Greptile SummaryThis PR introduces registry-driven world catalogs (
Confidence Score: 5/5Safe to merge — all changes are purely additive, fully backward-compatible, and covered by dedicated tests. The registry indirection is a clean drop-in replacement for direct catalog access, the D1 feedback-loop wiring is well-isolated behind null checks, and no existing interfaces are broken. The only finding is a minor incompleteness in EventOutcome (missing stressor_severity) that has no runtime impact today. No files require special attention; the daemon loop changes are straightforward and well-tested. Important Files Changed
Sequence DiagramsequenceDiagram
participant Daemon as HiveDaemon
participant EE as EventEngine
participant ER as EventRegistry
participant SS as SufferingState
participant IM as IdentityManager
Daemon->>EE: roll_events(agent_id, cycle)
EE->>ER: all() / get(event_id)
ER-->>EE: List[LifeEvent]
EE-->>Daemon: [event, ...]
Daemon->>EE: apply_choice(agent_id, event, choice_id, cycle)
Note over EE: choice.stressor → outcome.stressor_added
Note over EE: choice.resolves_stressor → outcome.stressor_resolved
EE-->>Daemon: EventOutcome
alt outcome.stressor_added
Daemon->>Daemon: re-lookup chosen from event.choices
Daemon->>SS: add_stressor(stressor_added, severity)
end
alt outcome.stressor_resolved
Daemon->>SS: resolve(stressor_resolved)
end
Daemon->>IM: update_narrative(agent_id, event.name, choice_description)
Note over Daemon: Goal abandonment path (new)
Daemon->>IM: update_narrative(agent_id, objective, Abandoned)
Note over Daemon: Goal generation path (new)
Daemon->>Daemon: "agent_stats = _stats.get(agent_id)"
Daemon->>EL: "ExistenceLoop(stats=agent_stats)"
Note over EL: stats injected into _build_prompt
Reviews (2): Last reviewed commit: "feat: wire simulation feedback loops (Ph..." | Re-trigger Greptile |
| Register your own without editing the catalog modules, or pass a custom registry to | ||
| an `EventEngine` / `WorldState` for an isolated content set. |
There was a problem hiding this comment.
The opening sentence of this section says a custom registry can be passed to
WorldState, but WorldState.__init__ has no registry parameter — it always calls JobRegistry.default(). A reader following the "custom registry to ... WorldState" hint will search for a non-existent parameter. The later sentence in the same section correctly describes the actual contract (modify the default registry before constructing WorldState), but the contradiction in the intro will cause confusion.
| Register your own without editing the catalog modules, or pass a custom registry to | |
| an `EventEngine` / `WorldState` for an isolated content set. | |
| Register your own without editing the catalog modules, or pass a custom `EventRegistry` | |
| to an `EventEngine` for per-engine isolation, or pre-populate `JobRegistry.default()` | |
| before constructing a `WorldState` to include custom jobs in every new world. |
| @classmethod | ||
| def default(cls) -> EventRegistry: | ||
| if cls._instance is None: | ||
| cls._instance = cls() | ||
| for event in EVENTS: | ||
| cls._instance.register(event) | ||
| return cls._instance |
There was a problem hiding this comment.
Partially-seeded singleton visible under concurrent access
Between cls._instance = cls() (line 41) and the completion of the seeding loop (lines 42-43), the GIL can be released. A second thread that calls default() at that moment will see _instance is not None and return an empty registry before any events are registered. The same window exists in JobRegistry.default(). This matches the pattern in the existing PatternRegistry, so it is a pre-existing concern in the codebase rather than a new regression — but worth noting since these registries are now used on hot paths (EventEngine construction, WorldState construction).
The simulation layers were modular but disconnected. Wire the three missing feedback loops with targeted, additive changes (no new event bus): Loop 1 -- world events -> stressors: - Choice gains optional stressor / stressor_severity / resolves_stressor; EventOutcome carries stressor_added / stressor_resolved (apply_choice sets them). - _process_life_events feeds the chosen outcome into the agent's SufferingState (add_stressor / resolve) -- add_stressor already tolerates new stressor names. - A few catalog events declare stressors (big_loss/burnout cause; windfall/rest resolve) so the loop is exercised by real content. Loop 2 -- stats -> goal generation (economy-gated): - GoalContext.agent_stats; ExistenceLoop takes optional stats and renders a 'Current condition' section (health/energy/happiness/reputation) in the prompt; the daemon passes self._stats.get(agent_id) into both goal-gen paths. Loop 3 -- outcomes -> narrative: - Goal abandonment now calls update_narrative (success already did); life events append a narrative entry. Reuses the existing 800-char update_narrative. Tests: outcome stressor fields, daemon event->stressor add + resolve + narrative, stats condition section present/absent. Docs: data-flow + feedback-loop note. 927 tests, ruff, format, mypy, mkdocs --strict all green. Fully additive.
Phase 3 — Simulation core flexibility (D2)
First slice of Phase 3 (post-0.5.0). Makes the simulation's content catalogs registry-driven instead of hardcoded module constants, mirroring the existing
StressorRegistry/PatternRegistry.Changes
world/registry.py—EventRegistryandJobRegistry: singletondefault()seeded from the built-inEVENTS/AVAILABLE_JOBS, plusregister()/get()/all()and_reset()(tests).EventEngine— accepts an optionalevents=EventRegistry(defaults to the built-in); content is now pluggable and per-engine. Replaces directEVENT_MAP/EVENTSaccess.WorldState— seeds its jobs fromJobRegistry.default()(local import breaks the registry↔state cycle), so registered jobs flow into new worlds.Why
Adding a life event or job previously meant editing catalog modules, and content was global. Now applications register content without touching the catalog, or pass a custom registry for an isolated set. This also unblocks D1 (feedback loops will key on event/stressor maps).
Tests
tests/test_world_registry.py— default seeding, register/get/all, replace-by-id, singleton,EventEnginehonoring a custom registry, andWorldStatepicking up a registered job. Existingtest_event_engine/test_world_statestill pass.Docs
New "Custom World Content (Events & Jobs)" extension section in
EXTENDING.mdanddocs/extending/index.md, plus an architecture extension-table row.Gate
922 tests · ruff · ruff format · mypy · mkdocs --strict — all green. Fully additive; no breaking changes.
Next in Phase 3
D1 (feedback loops via event/stressor map: world events → stressors, stats → goals, outcomes → identity/narrative), D3 (relationships/mood/chaptered narrative), and the deferred B2/B4 (cycle-phase hooks) which now have a real consumer.