-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Phase 3 simulation core — registry-driven world catalogs (D2) #29
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| """Registries for world content -- life events and jobs (Phase 3 D2). | ||
|
|
||
| Mirror ``StressorRegistry`` / ``PatternRegistry``: a default singleton seeded | ||
| from the built-in catalogs, plus ``register()`` / ``get()`` / ``all()`` so | ||
| applications can add content without editing the catalog modules, and pass a | ||
| custom registry per ``EventEngine`` / ``WorldState`` for isolated content sets. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from typing import ClassVar | ||
|
|
||
| from hive.world.event_catalog import EVENTS | ||
| from hive.world.events import LifeEvent | ||
| from hive.world.state import AVAILABLE_JOBS, Job | ||
|
|
||
|
|
||
| class EventRegistry: | ||
| """Extensible registry of life events. ``default()`` is seeded with EVENTS.""" | ||
|
|
||
| _instance: ClassVar[EventRegistry | None] = None | ||
|
|
||
| def __init__(self) -> None: | ||
| self._events: dict[str, LifeEvent] = {} | ||
|
|
||
| def register(self, event: LifeEvent) -> None: | ||
| """Add or replace an event by its event_id.""" | ||
| self._events[event.event_id] = event | ||
|
|
||
| def get(self, event_id: str) -> LifeEvent | None: | ||
| """Return the event with this id, or None.""" | ||
| return self._events.get(event_id) | ||
|
|
||
| def all(self) -> list[LifeEvent]: | ||
| """Return all registered events.""" | ||
| return list(self._events.values()) | ||
|
|
||
| @classmethod | ||
| def default(cls) -> EventRegistry: | ||
| if cls._instance is None: | ||
| cls._instance = cls() | ||
| for event in EVENTS: | ||
| cls._instance.register(event) | ||
| return cls._instance | ||
|
Comment on lines
+38
to
+44
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Between |
||
|
|
||
| @classmethod | ||
| def _reset(cls) -> None: | ||
| cls._instance = None | ||
|
|
||
|
|
||
| class JobRegistry: | ||
| """Extensible registry of jobs. ``default()`` is seeded with AVAILABLE_JOBS.""" | ||
|
|
||
| _instance: ClassVar[JobRegistry | None] = None | ||
|
|
||
| def __init__(self) -> None: | ||
| self._jobs: dict[str, Job] = {} | ||
|
|
||
| def register(self, job: Job) -> None: | ||
| """Add or replace a job by its job_id.""" | ||
| self._jobs[job.job_id] = job | ||
|
|
||
| def get(self, job_id: str) -> Job | None: | ||
| """Return the job with this id, or None.""" | ||
| return self._jobs.get(job_id) | ||
|
|
||
| def all(self) -> list[Job]: | ||
| """Return all registered jobs.""" | ||
| return list(self._jobs.values()) | ||
|
|
||
| @classmethod | ||
| def default(cls) -> JobRegistry: | ||
| if cls._instance is None: | ||
| cls._instance = cls() | ||
| for job in AVAILABLE_JOBS: | ||
| cls._instance.register(job) | ||
| return cls._instance | ||
|
|
||
| @classmethod | ||
| def _reset(cls) -> None: | ||
| cls._instance = None | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| """Tests for registry-driven world catalogs -- events and jobs (Phase 3 D2).""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| import pytest | ||
|
|
||
| from hive.world.event_catalog import EVENTS | ||
| from hive.world.event_engine import EventEngine | ||
| from hive.world.events import LifeEvent | ||
| from hive.world.registry import EventRegistry, JobRegistry | ||
| from hive.world.state import AVAILABLE_JOBS, Job, WorldState | ||
| from hive.world.stats import StatsManager | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _reset_registries(): | ||
| EventRegistry._reset() | ||
| JobRegistry._reset() | ||
| yield | ||
| EventRegistry._reset() | ||
| JobRegistry._reset() | ||
|
|
||
|
|
||
| class TestEventRegistry: | ||
| def test_default_seeded_with_builtin_events(self) -> None: | ||
| reg = EventRegistry.default() | ||
| assert len(reg.all()) == len(EVENTS) | ||
| assert reg.get("rent_increase") is not None | ||
|
|
||
| def test_register_and_get_custom_event(self) -> None: | ||
| reg = EventRegistry() | ||
| ev = LifeEvent( | ||
| event_id="lottery", | ||
| name="Lottery Win", | ||
| description="You won!", | ||
| category="financial", | ||
| choices=[], | ||
| ) | ||
| reg.register(ev) | ||
| assert reg.get("lottery") is ev | ||
| assert reg.get("missing") is None | ||
|
|
||
| def test_register_replaces_by_id(self) -> None: | ||
| reg = EventRegistry.default() | ||
| before = len(reg.all()) | ||
| reg.register( | ||
| LifeEvent( | ||
| event_id="rent_increase", | ||
| name="X", | ||
| description="x", | ||
| category="financial", | ||
| choices=[], | ||
| ) | ||
| ) | ||
| assert len(reg.all()) == before # replaced, not appended | ||
|
|
||
| def test_default_is_singleton(self) -> None: | ||
| assert EventRegistry.default() is EventRegistry.default() | ||
|
|
||
|
|
||
| class TestJobRegistry: | ||
| def test_default_seeded_with_builtin_jobs(self) -> None: | ||
| reg = JobRegistry.default() | ||
| assert len(reg.all()) == len(AVAILABLE_JOBS) | ||
| assert reg.get("analyst") is not None | ||
|
|
||
| def test_register_custom_job(self) -> None: | ||
| reg = JobRegistry() | ||
| reg.register(Job(job_id="pilot", title="Pilot", salary=200.0)) | ||
| assert reg.get("pilot").title == "Pilot" | ||
|
|
||
|
|
||
| class TestEngineUsesRegistry: | ||
| def test_engine_fires_only_registered_events(self, tmp_path: Path) -> None: | ||
| """An EventEngine with a custom registry sees only that registry's events.""" | ||
| reg = EventRegistry() | ||
| reg.register( | ||
| LifeEvent( | ||
| event_id="only_one", name="Only", description="d", category="misc", choices=[] | ||
| ) | ||
| ) | ||
| world = WorldState(tmp_path) | ||
| engine = EventEngine(StatsManager(tmp_path), world, hive_dir=tmp_path, events=reg) | ||
| assert engine._events.get("only_one") is not None | ||
| assert engine._events.get("rent_increase") is None # not in this registry | ||
|
|
||
| def test_engine_defaults_to_builtin_registry(self, tmp_path: Path) -> None: | ||
| world = WorldState(tmp_path) | ||
| engine = EventEngine(StatsManager(tmp_path), world, hive_dir=tmp_path) | ||
| assert engine._events.get("rent_increase") is not None | ||
|
|
||
|
|
||
| class TestWorldStateUsesJobRegistry: | ||
| def test_registered_job_appears_in_new_world(self, tmp_path: Path) -> None: | ||
| """A job registered before WorldState construction is picked up.""" | ||
| JobRegistry.default().register(Job(job_id="astronaut", title="Astronaut", salary=300.0)) | ||
| world = WorldState(tmp_path / "a") | ||
| job_ids = {j.job_id for j in world._jobs} | ||
| assert "astronaut" in job_ids | ||
| assert "analyst" in job_ids # built-ins still present |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
WorldState, butWorldState.__init__has no registry parameter — it always callsJobRegistry.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 constructingWorldState), but the contradiction in the intro will cause confusion.