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 +71 -0
- ecopoesis/__version__.py +9 -0
- ecopoesis/_gen_pb2.py +37 -0
- ecopoesis/actions.py +547 -0
- ecopoesis/cli.py +1338 -0
- ecopoesis/climate.py +310 -0
- ecopoesis/controls.py +102 -0
- ecopoesis/geology.py +376 -0
- ecopoesis/life.py +572 -0
- ecopoesis/persistence.py +667 -0
- ecopoesis/render.py +971 -0
- ecopoesis/resources.py +316 -0
- ecopoesis/simulation.py +484 -0
- ecopoesis/simulation_pb2.py +36 -0
- ecopoesis/state.py +194 -0
- ecopoesis/terrain.py +209 -0
- ecopoesis-0.1.0.dist-info/METADATA +266 -0
- ecopoesis-0.1.0.dist-info/RECORD +21 -0
- ecopoesis-0.1.0.dist-info/WHEEL +5 -0
- ecopoesis-0.1.0.dist-info/entry_points.txt +2 -0
- ecopoesis-0.1.0.dist-info/top_level.txt +1 -0
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
|
+
|
ecopoesis/__version__.py
ADDED
|
@@ -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
|
+
]
|