ecopoesis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ecopoesis/__init__.py ADDED
@@ -0,0 +1,71 @@
1
+ """SimEarth package exports."""
2
+
3
+ from .__version__ import __version__ # noqa: F401
4
+ from .cli import main # noqa: F401
5
+ from .controls import EnvironmentControls
6
+ from .geology import GeologyEngine, GeologyState
7
+ from .life import Life, LifeState, Species, is_habitable
8
+ from .resources import MAX_RESOURCE, ResourceState, Resources
9
+ from .simulation import Simulation
10
+ from .state import SUPPORTED_VERSIONS, SimulationState
11
+ from .terrain import Terrain, TerrainState, ELEVATION_MIN, ELEVATION_MAX
12
+ from .climate import Climate, ClimateState, TEMP_MIN, TEMP_MAX, PRECIP_MIN, PRECIP_MAX
13
+ from .persistence import (
14
+ Format,
15
+ CURRENT_SCHEMA,
16
+ load_simulation,
17
+ load_simulation_json,
18
+ load_simulation_protobuf,
19
+ load_simulation_v1,
20
+ save_simulation,
21
+ save_simulation_json,
22
+ save_simulation_protobuf,
23
+ save_simulation_v1,
24
+ )
25
+
26
+ __all__ = [
27
+ # Package metadata (Slice 11)
28
+ "__version__",
29
+ # Simulation core
30
+ "Simulation",
31
+ "SimulationState",
32
+ "SUPPORTED_VERSIONS",
33
+ # Terrain layer (Slice 3)
34
+ "Terrain",
35
+ "TerrainState",
36
+ "ELEVATION_MIN",
37
+ "ELEVATION_MAX",
38
+ # Climate layer (Slice 3)
39
+ "Climate",
40
+ "ClimateState",
41
+ "TEMP_MIN",
42
+ "TEMP_MAX",
43
+ "PRECIP_MIN",
44
+ "PRECIP_MAX",
45
+ # Life layer (Slice 4)
46
+ "Life",
47
+ "LifeState",
48
+ "Species",
49
+ "is_habitable",
50
+ # Resources layer (Slice 6)
51
+ "Resources",
52
+ "ResourceState",
53
+ "MAX_RESOURCE",
54
+ # Environment controls (Slice 6)
55
+ "EnvironmentControls",
56
+ # Geology layer (Slice 7)
57
+ "GeologyEngine",
58
+ "GeologyState",
59
+ # Persistence
60
+ "Format",
61
+ "CURRENT_SCHEMA",
62
+ "save_simulation",
63
+ "load_simulation",
64
+ "save_simulation_json",
65
+ "load_simulation_json",
66
+ "save_simulation_protobuf",
67
+ "load_simulation_protobuf",
68
+ "save_simulation_v1",
69
+ "load_simulation_v1",
70
+ ]
71
+
@@ -0,0 +1,9 @@
1
+ """Single-source-of-truth version for the ecopoesis package.
2
+
3
+ This file is intentionally minimal so it can be imported from any context
4
+ (including build scripts and runtime) without pulling in heavier dependencies
5
+ like ``ecopoesis.simulation_pb2``. Both ``pyproject.toml`` and the public
6
+ ``ecopoesis.__version__`` re-export should stay in lock-step with this string.
7
+ """
8
+
9
+ __version__ = "0.1.0"
ecopoesis/_gen_pb2.py ADDED
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env python3
2
+ """Generate simulation_pb2.py from simulation.proto using grpc_tools.protoc."""
3
+ import os
4
+ import sys
5
+
6
+ PROTO_DIR = os.path.dirname(os.path.abspath(__file__))
7
+ PROTO_FILE = os.path.join(PROTO_DIR, "simulation.proto")
8
+ OUT_DIR = PROTO_DIR
9
+
10
+ try:
11
+ from grpc_tools import protoc
12
+
13
+ rc = protoc.main(
14
+ [
15
+ "grpc_tools.protoc",
16
+ f"-I{PROTO_DIR}",
17
+ f"--python_out={OUT_DIR}",
18
+ PROTO_FILE,
19
+ ]
20
+ )
21
+ if rc != 0:
22
+ print("protoc returned non-zero exit code:", rc)
23
+ sys.exit(rc)
24
+ print(f"Generated simulation_pb2.py in {OUT_DIR}")
25
+ except ImportError:
26
+ # Fallback: try subprocess protoc binary
27
+ import subprocess
28
+
29
+ result = subprocess.run(
30
+ ["protoc", f"-I{PROTO_DIR}", f"--python_out={OUT_DIR}", PROTO_FILE],
31
+ capture_output=True,
32
+ text=True,
33
+ )
34
+ if result.returncode != 0:
35
+ print("protoc error:", result.stderr)
36
+ sys.exit(result.returncode)
37
+ print(f"Generated simulation_pb2.py in {OUT_DIR}")
ecopoesis/actions.py ADDED
@@ -0,0 +1,547 @@
1
+ """Player actions layer for SimEarth (Slice 9).
2
+
3
+ A player (or the CLI) can queue ``Action`` records into a ``CommandLog``
4
+ attached to the live ``SimulationState``. At the start of each tick the
5
+ simulation **drains** every action whose ``action.tick == state.tick``
6
+ **before** the core LCG runs, so the action's effects are baked into
7
+ the per-tick snapshot in the canonical order.
8
+
9
+ Design contracts
10
+ ================
11
+
12
+ * ``Action`` is a frozen-ish dataclass (mutable ``payload`` dict so the
13
+ CLI can fill it in-place, but ``tick``/``type``/``rng_cost`` are
14
+ immutable after construction). ``rng_cost`` is non-negative; the
15
+ handler MAY consume up to that many RNG draws if it declares so.
16
+ ``rng_cost == 0`` (default) is a strict no-RNG contract.
17
+
18
+ * ``CommandLog`` is the ordered, append-only log of pending actions.
19
+ ``append(action)`` rejects past-tick submissions with
20
+ :py:class:`ActionOrderError` — same-tick and future-tick submissions
21
+ are accepted in submission order (the spec's "out-of-order = strictly
22
+ past-tick" rule).
23
+
24
+ * ``Actions`` is the engine — a registry mapping ``type`` strings to
25
+ handler callables ``Callable[[SimulationState, dict], None]``. Three
26
+ handlers ship in this slice: ``seed_life``, ``adjust_controls``, and
27
+ ``trigger_eruption``. All three are deterministic and RNG-free
28
+ (``rng_cost = 0`` by default), so adding them to a run does not
29
+ perturb the slice-7 RNG budget for any other layer.
30
+
31
+ * :func:`apply_action` is the top-level entry point used by
32
+ ``Simulation.advance`` while draining the log: it validates the
33
+ RNG-cost contract, looks up the handler, and invokes it with
34
+ ``(state, payload)``.
35
+
36
+ Why deterministic handlers only?
37
+ ================================
38
+
39
+ The Slice 9 spec requires ``save -> load -> advance(N)`` to be
40
+ byte-identical to a no-save baseline run (same seed, same actions).
41
+ The easiest way to keep that invariant is to mandate that handlers do
42
+ not consume RNG draws unless they explicitly declare so via
43
+ ``rng_cost``. All three built-in handlers declare ``rng_cost = 0`` so
44
+ they are pure functions of the existing state plus their payload — no
45
+ hidden RNG calls, no surprises for downstream snapshots.
46
+ """
47
+
48
+ from __future__ import annotations
49
+
50
+ from dataclasses import dataclass, field
51
+ from typing import TYPE_CHECKING, Any, Callable
52
+
53
+ from .controls import EnvironmentControls
54
+ from .life import Species
55
+ from .resources import MAX_RESOURCE
56
+
57
+ if TYPE_CHECKING:
58
+ # Imported only for type-checking to break the import cycle:
59
+ # ``state.py`` imports ``CommandLog`` from this module, and
60
+ # ``actions.py`` annotates ``SimulationState`` parameters as a
61
+ # forward reference. Runtime callers always pass a real
62
+ # ``SimulationState`` instance so the annotation is purely
63
+ # cosmetic for IDEs / type-checkers.
64
+ from .state import SimulationState
65
+
66
+
67
+ # --------------------------------------------------------------------------- #
68
+ # Typed errors #
69
+ # --------------------------------------------------------------------------- #
70
+
71
+
72
+ class UnknownActionError(RuntimeError):
73
+ """Raised when :func:`apply_action` is given an unregistered ``type``.
74
+
75
+ Subclasses :py:class:`RuntimeError` so generic CLI catchers treat it
76
+ like any other runtime failure but downstream callers can narrow on
77
+ the exact type without parsing strings.
78
+ """
79
+
80
+
81
+ class ActionOrderError(RuntimeError):
82
+ """Raised when an action is submitted with a past-tick stamp.
83
+
84
+ The Slice 9 spec defines "out-of-order" as strictly past-tick
85
+ (``action.tick < state.tick``). Same-tick and future-tick actions
86
+ are accepted in submission order.
87
+ """
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # Action + CommandLog #
92
+ # --------------------------------------------------------------------------- #
93
+
94
+
95
+ @dataclass(slots=True)
96
+ class Action:
97
+ """A single player command.
98
+
99
+ Parameters
100
+ ----------
101
+ tick : int
102
+ The simulation tick at which this action should be applied.
103
+ The simulation drains actions whose ``tick`` matches the
104
+ current tick **at the start of that tick, before the core
105
+ LCG** runs. ``tick`` must be ``>= state.tick`` at submission
106
+ time or :py:class:`ActionOrderError` is raised.
107
+ type : str
108
+ The action type — looked up in the :class:`Actions` registry.
109
+ Unknown types raise :py:class:`UnknownActionError` at apply time.
110
+ payload : dict
111
+ Free-form per-type data. ``seed_life`` expects ``{"species":
112
+ str, "cells": list[[row, col], ...]}``; ``adjust_controls``
113
+ expects ``{"temperature_offset": int, "rainfall_offset": int}``;
114
+ ``trigger_eruption`` expects ``{"cell": [row, col]}`` (optional
115
+ — defaults to the deterministic origin ``[0, 0]``). The payload
116
+ is owned by the action instance after construction; handlers
117
+ MUST NOT mutate it.
118
+ rng_cost : int
119
+ Non-negative declaration of how many RNG draws the handler
120
+ intends to consume. The default ``0`` enforces the no-RNG
121
+ contract: a handler with ``rng_cost == 0`` that calls
122
+ ``random.Random`` is a contract violation and is rejected by
123
+ the test suite.
124
+ """
125
+
126
+ tick: int
127
+ type: str
128
+ payload: dict = field(default_factory=dict)
129
+ rng_cost: int = 0
130
+
131
+ def __post_init__(self) -> None:
132
+ # Defensive validation so a malformed Action fails fast (and
133
+ # before it pollutes a CommandLog).
134
+ if not isinstance(self.tick, int) or isinstance(self.tick, bool):
135
+ raise TypeError(f"Action.tick must be int; got {type(self.tick).__name__}")
136
+ if not isinstance(self.type, str):
137
+ raise TypeError(f"Action.type must be str; got {type(self.type).__name__}")
138
+ if not isinstance(self.payload, dict):
139
+ raise TypeError(f"Action.payload must be dict; got {type(self.payload).__name__}")
140
+ if not isinstance(self.rng_cost, int) or isinstance(self.rng_cost, bool):
141
+ raise TypeError(
142
+ f"Action.rng_cost must be int; got {type(self.rng_cost).__name__}"
143
+ )
144
+
145
+ def to_dict(self) -> dict[str, Any]:
146
+ """Serialise to a JSON-friendly dict.
147
+
148
+ ``payload`` is deep-copied (top level only — handlers MUST treat
149
+ it as read-only after construction, so the top-level copy is
150
+ enough to keep callers from accidentally aliasing into the log).
151
+ """
152
+ return {
153
+ "tick": int(self.tick),
154
+ "type": str(self.type),
155
+ "payload": {k: v for k, v in self.payload.items()},
156
+ "rng_cost": int(self.rng_cost),
157
+ }
158
+
159
+ @classmethod
160
+ def from_dict(cls, data: dict[str, Any]) -> "Action":
161
+ """Inverse of :py:meth:`to_dict`. Missing fields default safely."""
162
+ return cls(
163
+ tick=int(data.get("tick", 0)),
164
+ type=str(data.get("type", "")),
165
+ payload=dict(data.get("payload") or {}),
166
+ rng_cost=int(data.get("rng_cost", 0)),
167
+ )
168
+
169
+
170
+ @dataclass(slots=True)
171
+ class CommandLog:
172
+ """Append-only ordered log of pending player actions.
173
+
174
+ Tracks ``current_tick`` (the highest tick the simulation has
175
+ advanced to so far) so :py:meth:`append` can reject past-tick
176
+ submissions without the caller having to pass the tick in. The
177
+ simulation calls :py:meth:`advance_to` once per tick before draining
178
+ so the log's internal tick stays in sync with ``SimulationState.tick``.
179
+
180
+ Attributes
181
+ ----------
182
+ entries : list[Action]
183
+ The ordered list of pending actions in submission order.
184
+ ``drain_for_tick`` removes elements as it returns them, so the
185
+ list shrinks monotonically over the run.
186
+ current_tick : int
187
+ The simulation tick the log has been advanced to so far.
188
+ Initialised to ``0`` so the very first ``apply_command``
189
+ submission (with ``tick == 1``) is accepted as a future-tick
190
+ action.
191
+ """
192
+
193
+ entries: list[Action] = field(default_factory=list)
194
+ current_tick: int = 0
195
+
196
+ def append(self, action: Action) -> None:
197
+ """Append *action* to the log, validating ordering.
198
+
199
+ Raises
200
+ ------
201
+ ActionOrderError
202
+ If ``action.tick < self.current_tick`` — the action would
203
+ never be drained because the simulation has already moved
204
+ past its target tick. Same-tick and future-tick
205
+ submissions are accepted.
206
+ """
207
+ if action.tick < self.current_tick:
208
+ raise ActionOrderError(
209
+ f"action tick {action.tick} is past current tick {self.current_tick}"
210
+ )
211
+ self.entries.append(action)
212
+
213
+ def advance_to(self, tick: int) -> None:
214
+ """Move the log's *current_tick* forward to *tick* (monotonic).
215
+
216
+ ``Simulation.advance`` calls this once per tick — *before* the
217
+ drain — so subsequent ``append`` calls correctly classify same-
218
+ and future-tick submissions.
219
+ """
220
+ if tick > self.current_tick:
221
+ self.current_tick = tick
222
+
223
+ def drain_for_tick(self, tick: int) -> list[Action]:
224
+ """Return (and remove) all actions whose ``tick == tick``.
225
+
226
+ Returned in submission order so the drain is fully deterministic
227
+ regardless of how the user interleaved future-tick submissions.
228
+ """
229
+ kept: list[Action] = []
230
+ drained: list[Action] = []
231
+ for action in self.entries:
232
+ if action.tick == tick:
233
+ drained.append(action)
234
+ else:
235
+ kept.append(action)
236
+ # Replace in-place to keep the underlying list identity (some
237
+ # callers cache a reference; cheaper than allocating a fresh
238
+ # list every tick).
239
+ self.entries.clear()
240
+ self.entries.extend(kept)
241
+ return drained
242
+
243
+ def is_empty(self) -> bool:
244
+ """``True`` when no pending actions remain."""
245
+ return not self.entries
246
+
247
+ def __len__(self) -> int: # pragma: no cover - convenience
248
+ return len(self.entries)
249
+
250
+ def to_list(self) -> list[Action]:
251
+ """Return a shallow copy of the entries list (read-only view)."""
252
+ return list(self.entries)
253
+
254
+ def to_dict(self) -> dict[str, Any]:
255
+ """Serialise to a JSON-friendly dict."""
256
+ return {
257
+ "entries": [a.to_dict() for a in self.entries],
258
+ "current_tick": int(self.current_tick),
259
+ }
260
+
261
+ @classmethod
262
+ def from_dict(cls, data: dict[str, Any]) -> "CommandLog":
263
+ """Inverse of :py:meth:`to_dict`. Empty input → empty log."""
264
+ entries_raw = data.get("entries") or []
265
+ return cls(
266
+ entries=[Action.from_dict(d) for d in entries_raw],
267
+ current_tick=int(data.get("current_tick", 0)),
268
+ )
269
+
270
+
271
+ # --------------------------------------------------------------------------- #
272
+ # Handler type alias #
273
+ # --------------------------------------------------------------------------- #
274
+
275
+
276
+ # A handler mutates ``state`` in place based on its ``payload`` and
277
+ # returns ``None``. Handlers MUST be deterministic and MUST NOT consume
278
+ # RNG draws unless ``action.rng_cost > 0`` (the ``apply_action`` entry
279
+ # point enforces that contract at apply time).
280
+ # ``SimulationState`` is a forward reference — the real type lives in
281
+ # ``state.py`` which imports this module for ``CommandLog``. The alias
282
+ # below uses ``Callable[..., None]`` (which is functionally equivalent
283
+ # at runtime) and the precise signature is documented in the comment
284
+ # for IDEs / type-checkers.
285
+ ActionHandler = Callable[..., None] # Callable[[SimulationState, dict], None]
286
+
287
+
288
+ # --------------------------------------------------------------------------- #
289
+ # Built-in handlers #
290
+ # --------------------------------------------------------------------------- #
291
+
292
+
293
+ def _initial_population(species_key: str) -> int:
294
+ """Initial seed population placed on each cell by ``seed_life``.
295
+
296
+ Mirrors :func:`ecopoesis.life._initial_population` so the action has
297
+ the same defaults as the CLI ``--seed-life`` flag. Mirrored here to
298
+ avoid an import cycle through ``life.py`` (handlers run during
299
+ drain, before any per-tick ``Life.advance_tick``).
300
+ """
301
+ if species_key == Species.MICROBE.value:
302
+ return 5
303
+ if species_key == Species.PLANT.value:
304
+ return 3
305
+ raise ValueError(f"Unknown species: {species_key!r}")
306
+
307
+
308
+ def _seed_life(state: SimulationState, payload: dict) -> None:
309
+ """Seed a species' population at the given grid cells.
310
+
311
+ Payload
312
+ -------
313
+ ``species`` : str
314
+ Canonical species name (``"MICROBE"`` or ``"PLANT"`` —
315
+ case-insensitive). Anything else raises ``ValueError``.
316
+ ``cells`` : list[tuple[int, int]]
317
+ ``[row, col]`` coordinates to seed. Out-of-bounds cells are
318
+ silently skipped (matches the ``Life.place`` contract).
319
+
320
+ Effect
321
+ ------
322
+ Ensures ``state.life_state.populations[species]`` exists as a
323
+ flat ``rows * cols`` grid (default zeros) and bumps each requested
324
+ cell by ``_initial_population(species)``. No RNG draws.
325
+ """
326
+ species_raw = payload.get("species") or payload.get("seed_life")
327
+ if not species_raw:
328
+ raise ValueError("seed_life payload missing 'species'")
329
+ species_key = str(species_raw).upper()
330
+ # Normalise via the Species enum so ``"microbe"`` and ``"MICROBE"``
331
+ # both work, and an unknown name raises ValueError early.
332
+ try:
333
+ species_key = Species(species_key).value
334
+ except ValueError as exc:
335
+ raise ValueError(f"seed_life: {exc}") from exc
336
+
337
+ cells_raw = payload.get("cells") or []
338
+ if not isinstance(cells_raw, (list, tuple)):
339
+ raise ValueError("seed_life: 'cells' must be a list of [row, col] pairs")
340
+
341
+ life = state.life_state
342
+ rows, cols = life.rows, life.cols
343
+ grid = life.populations.get(species_key)
344
+ if grid is None:
345
+ grid = [0] * (rows * cols)
346
+ life.populations[species_key] = grid
347
+ pop = _initial_population(species_key)
348
+ for cell in cells_raw:
349
+ if not isinstance(cell, (list, tuple)) or len(cell) != 2:
350
+ raise ValueError(f"seed_life: invalid cell entry {cell!r}")
351
+ r, c = int(cell[0]), int(cell[1])
352
+ if 0 <= r < rows and 0 <= c < cols:
353
+ grid[r * cols + c] += pop
354
+
355
+
356
+ def _adjust_controls(state: SimulationState, payload: dict) -> None:
357
+ """Update ``EnvironmentControls`` offsets and apply the delta to climate.
358
+
359
+ Payload
360
+ -------
361
+ ``temperature_offset`` : int
362
+ New ``temperature_offset`` value (replaces the old one; not a
363
+ delta). Defaults to the existing value when omitted.
364
+ ``rainfall_offset`` : int
365
+ New ``rainfall_offset`` value (replaces the old one). Defaults
366
+ to the existing value when omitted.
367
+
368
+ Effect
369
+ ------
370
+ Replaces ``state.controls`` with the new ``EnvironmentControls``
371
+ instance and applies the **delta** (new − old) to
372
+ ``state.climate_state`` so existing climate cells pick up the
373
+ offset change without losing their per-tick perturbation history.
374
+ No RNG draws.
375
+ """
376
+ old = state.controls
377
+ new_t = int(payload.get("temperature_offset", old.temperature_offset))
378
+ new_r = int(payload.get("rainfall_offset", old.rainfall_offset))
379
+ delta_t = new_t - int(old.temperature_offset)
380
+ delta_r = new_r - int(old.rainfall_offset)
381
+ if delta_t == 0 and delta_r == 0:
382
+ # No-op fast path: avoid constructing an EnvironmentControls
383
+ # and a ClimateState mutation when nothing changed.
384
+ return
385
+ # Apply the delta to the climate state. ``EnvironmentControls.apply``
386
+ # adds the offset and clamps into the existing climate bounds, so
387
+ # the new cells stay valid even when the offset is large.
388
+ delta = EnvironmentControls(
389
+ temperature_offset=delta_t,
390
+ rainfall_offset=delta_r,
391
+ )
392
+ delta.apply(state.climate_state)
393
+ state.controls = EnvironmentControls(temperature_offset=new_t, rainfall_offset=new_r)
394
+
395
+
396
+ def _trigger_eruption(state: SimulationState, payload: dict) -> None:
397
+ """Apply one deterministic volcanic eruption deposit.
398
+
399
+ Payload
400
+ -------
401
+ ``cell`` : list[int, int]
402
+ ``[row, col]`` coordinate to receive the deposit. Defaults to
403
+ ``[0, 0]`` so the action is fully deterministic without any
404
+ payload. Out-of-bounds cells are silently skipped (matches
405
+ the slice-7 eruption contract).
406
+
407
+ Effect
408
+ ------
409
+ Adds :data:`ecopoesis.geology.ERUPTION_MINERAL_DEPOSIT` minerals to
410
+ the requested cell, clamped into ``[0, MAX_RESOURCE]``. No RNG
411
+ draws. When ``state.resources_state.minerals`` is empty (the
412
+ default-constructed state) the deposit is a no-op.
413
+ """
414
+ from .geology import ERUPTION_MINERAL_DEPOSIT # local import: avoids cycles
415
+
416
+ cell = payload.get("cell") or [0, 0]
417
+ if not isinstance(cell, (list, tuple)) or len(cell) != 2:
418
+ raise ValueError(f"trigger_eruption: invalid cell {cell!r}")
419
+ r, c = int(cell[0]), int(cell[1])
420
+ res = state.resources_state
421
+ rows, cols = res.rows, res.cols
422
+ if not (0 <= r < rows and 0 <= c < cols):
423
+ return
424
+ if not res.minerals:
425
+ return
426
+ idx = r * cols + c
427
+ new_val = res.minerals[idx] + ERUPTION_MINERAL_DEPOSIT
428
+ if new_val < 0:
429
+ new_val = 0
430
+ elif new_val > MAX_RESOURCE:
431
+ new_val = MAX_RESOURCE
432
+ res.minerals[idx] = new_val
433
+
434
+
435
+ # --------------------------------------------------------------------------- #
436
+ # Actions engine #
437
+ # --------------------------------------------------------------------------- #
438
+
439
+
440
+ class Actions:
441
+ """Registry mapping action ``type`` strings to handler callables.
442
+
443
+ The default registry ships with three handlers
444
+ (``seed_life``/``adjust_controls``/``trigger_eruption``); callers
445
+ may register additional handlers with :py:meth:`register`. The
446
+ instance is mutable so tests can swap handlers; the module-level
447
+ :data:`DEFAULT_ACTIONS` is the singleton used by ``apply_action``
448
+ and the simulation.
449
+ """
450
+
451
+ def __init__(self) -> None:
452
+ self._handlers: dict[str, ActionHandler] = {}
453
+
454
+ def register(self, name: str, handler: ActionHandler) -> None:
455
+ """Register *handler* under *name*. Overwrites any existing entry."""
456
+ if not isinstance(name, str) or not name:
457
+ raise ValueError(f"action name must be a non-empty string; got {name!r}")
458
+ if not callable(handler):
459
+ raise TypeError(f"action handler for {name!r} must be callable")
460
+ self._handlers[name] = handler
461
+
462
+ def unregister(self, name: str) -> None:
463
+ """Remove the handler registered under *name* (no-op if absent)."""
464
+ self._handlers.pop(name, None)
465
+
466
+ def is_known(self, name: str) -> bool:
467
+ """``True`` when a handler is registered under *name*."""
468
+ return name in self._handlers
469
+
470
+ def known_types(self) -> list[str]:
471
+ """Return a snapshot of the registered handler names (sorted)."""
472
+ return sorted(self._handlers)
473
+
474
+ def apply(self, state: SimulationState, action: Action) -> None:
475
+ """Apply *action* to *state* via the registered handler.
476
+
477
+ Raises
478
+ ------
479
+ UnknownActionError
480
+ If no handler is registered for ``action.type``.
481
+ """
482
+ handler = self._handlers.get(action.type)
483
+ if handler is None:
484
+ raise UnknownActionError(f"unknown action type: {action.type!r}")
485
+ handler(state, action.payload)
486
+
487
+
488
+ # Module-level singleton — the canonical registry used by
489
+ # ``apply_action`` and the simulation. Tests can mutate it (register /
490
+ # unregister) and reset to the defaults with :func:`_install_defaults`.
491
+ DEFAULT_ACTIONS = Actions()
492
+
493
+
494
+ def _install_defaults(actions: Actions = DEFAULT_ACTIONS) -> Actions:
495
+ """Install the three built-in handlers on *actions* (idempotent)."""
496
+ actions.register("seed_life", _seed_life)
497
+ actions.register("adjust_controls", _adjust_controls)
498
+ actions.register("trigger_eruption", _trigger_eruption)
499
+ return actions
500
+
501
+
502
+ # Eagerly install the defaults at import time so ``apply_action`` works
503
+ # without callers having to remember to wire the registry up.
504
+ _install_defaults()
505
+
506
+
507
+ # --------------------------------------------------------------------------- #
508
+ # apply_action #
509
+ # --------------------------------------------------------------------------- #
510
+
511
+
512
+ def apply_action(state: SimulationState, action: Action) -> None:
513
+ """Apply *action* to *state* using the registered handler.
514
+
515
+ This is the top-level entry point used by
516
+ ``Simulation.advance`` during the per-tick drain phase. It is
517
+ also exposed for direct use by tests and the CLI ``replay``
518
+ subcommand.
519
+
520
+ Contract
521
+ --------
522
+ * ``action.rng_cost >= 0`` is validated here (a negative cost is
523
+ nonsensical and rejected as a ``ValueError``).
524
+ * ``action.type`` must be registered with :data:`DEFAULT_ACTIONS`
525
+ — otherwise :py:class:`UnknownActionError` is raised.
526
+ * The handler runs synchronously with ``(state, action.payload)``
527
+ as its arguments. Handlers MUST mutate ``state`` in place and
528
+ MUST be deterministic unless ``action.rng_cost > 0`` declares
529
+ otherwise.
530
+ """
531
+ if action.rng_cost < 0:
532
+ raise ValueError(
533
+ f"Action.rng_cost must be non-negative; got {action.rng_cost}"
534
+ )
535
+ DEFAULT_ACTIONS.apply(state, action)
536
+
537
+
538
+ __all__ = [
539
+ "Action",
540
+ "ActionHandler",
541
+ "ActionOrderError",
542
+ "Actions",
543
+ "CommandLog",
544
+ "DEFAULT_ACTIONS",
545
+ "UnknownActionError",
546
+ "apply_action",
547
+ ]