foliot 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.
foliot/__init__.py ADDED
@@ -0,0 +1,61 @@
1
+ """foliot -- a deterministic tick-driven simulation core.
2
+
3
+ A clock, a queue of scheduled things, a way to look up what to run, and a
4
+ source of addressable randomness. It has never heard of a target, location,
5
+ environment, activity, or combat rule.
6
+
7
+ The value is in five guarantees, not in the object graph:
8
+
9
+ 1. Same seed, same history.
10
+ 2. Reordering the queue cannot change outcomes.
11
+ 3. The clock does not drift.
12
+ 4. Nothing pending is lost, and nothing is applied twice.
13
+ 5. Time is injectable -- ten million ticks run in a unit test.
14
+
15
+ Everything re-exported here is public API: a moved name is a breaking change.
16
+ Anything not listed in `__all__` is internal and free to move.
17
+ """
18
+
19
+ from foliot.actions import (
20
+ ActionBinding,
21
+ ActionState,
22
+ Active,
23
+ BaseAction,
24
+ Bound,
25
+ Suspended,
26
+ Unbound,
27
+ )
28
+ from foliot.context import FinalizationContext, TickContext, TickFinalizer
29
+ from foliot.drivers import Driver, ManualDriver, RealtimeDriver
30
+ from foliot.effects import Effect
31
+ from foliot.engine import Simulation
32
+ from foliot.ids import EntityId, SuspensionId, Tick
33
+ from foliot.rng import Rng, counter_rng, new_world_seed
34
+ from foliot.stores import MemoryStore, Store, Txn
35
+
36
+ __all__ = [
37
+ "ActionBinding",
38
+ "ActionState",
39
+ "Active",
40
+ "BaseAction",
41
+ "Bound",
42
+ "Driver",
43
+ "Effect",
44
+ "EntityId",
45
+ "FinalizationContext",
46
+ "ManualDriver",
47
+ "MemoryStore",
48
+ "RealtimeDriver",
49
+ "Rng",
50
+ "Simulation",
51
+ "Store",
52
+ "Suspended",
53
+ "SuspensionId",
54
+ "Tick",
55
+ "TickContext",
56
+ "TickFinalizer",
57
+ "Txn",
58
+ "Unbound",
59
+ "counter_rng",
60
+ "new_world_seed",
61
+ ]
@@ -0,0 +1,7 @@
1
+ """One internal exception shared across the optional Event boundary."""
2
+
3
+ __all__ = ["EventConfigurationError"]
4
+
5
+
6
+ class EventConfigurationError(RuntimeError):
7
+ """Event API was used without a matching Event-enabled configuration."""
foliot/actions.py ADDED
@@ -0,0 +1,275 @@
1
+ """Game actions with engine-owned lifecycle bookkeeping.
2
+
3
+ Every game action that enters foliot's queue inherits `BaseAction`. This is
4
+ deliberately different from the library's structural ports: binding, stable
5
+ identity, and suspension are invariants every game needs and must implement in
6
+ exactly the same way.
7
+
8
+ An action always has a complete binding value. It starts `Unbound`; the store
9
+ changes it to `Bound(seq, state)` on first successful admission. The same game
10
+ object -- including all subclass fields -- survives every reschedule,
11
+ suspension, and resume, and its `seq` never changes.
12
+ """
13
+
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass, field
16
+ from typing import Literal
17
+
18
+ from foliot.context import TickContext
19
+ from foliot.ids import EntityId, SuspensionId, Tick
20
+
21
+ __all__ = [
22
+ "ActionBinding",
23
+ "ActionState",
24
+ "Active",
25
+ "BaseAction",
26
+ "Bound",
27
+ "Suspended",
28
+ "Unbound",
29
+ ]
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class Active:
34
+ """Queue state of an admitted action that is eligible to run.
35
+
36
+ Attributes:
37
+ due_tick: Future deadline, or `None` for an action due every tick.
38
+ status: Stable discriminator useful when persisting the state.
39
+ """
40
+
41
+ due_tick: Tick | None
42
+ status: Literal["active"] = field(default="active", init=False)
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Suspended:
47
+ """Queue state of an admitted action that is temporarily paused.
48
+
49
+ Attributes:
50
+ suspended_at: Tick at which the pause began.
51
+ suspended_by: Handle that must be used to resume the action.
52
+ due_tick: Deadline preserved from the active state, or `None` for a
53
+ recurring action.
54
+ status: Stable discriminator useful when persisting the state.
55
+ """
56
+
57
+ suspended_at: Tick
58
+ suspended_by: SuspensionId
59
+ due_tick: Tick | None
60
+ status: Literal["suspended"] = field(default="suspended", init=False)
61
+
62
+
63
+ type ActionState = Active | Suspended
64
+
65
+
66
+ @dataclass(frozen=True, slots=True)
67
+ class Unbound:
68
+ """A complete game action that has not entered the queue yet."""
69
+
70
+
71
+ @dataclass(frozen=True, slots=True)
72
+ class Bound:
73
+ """The store-owned metadata of an admitted action.
74
+
75
+ `seq` is assigned once, on first admission, and is carried unchanged through
76
+ every replacement of `state`. It is the action's replay identity, not its
77
+ position in a due list.
78
+
79
+ Attributes:
80
+ seq: Positive sequence number assigned by the store.
81
+ state: Current active or suspended queue state.
82
+ """
83
+
84
+ seq: int
85
+ state: ActionState
86
+
87
+
88
+ type ActionBinding = Unbound | Bound
89
+
90
+
91
+ class BaseAction[W](ABC):
92
+ """Mandatory blueprint for every game action entering foliot's queue.
93
+
94
+ The base owns only the lifecycle fields foliot needs. Subclasses keep all
95
+ game-owned payload -- target, poison damage, tick interval, arrival
96
+ deadline, and so on -- on the same object and implement `process()`.
97
+
98
+ Args:
99
+ entity_id: Opaque identity of the entity that owns the action.
100
+ suspendable: Whether owner-level suspension requests may pause it.
101
+ """
102
+
103
+ __slots__ = ("_binding", "_entity_id", "_suspendable")
104
+
105
+ def __init__(self, entity_id: EntityId, *, suspendable: bool) -> None:
106
+ self._entity_id = entity_id
107
+ self._suspendable = suspendable
108
+ self._binding: ActionBinding = Unbound()
109
+
110
+ @property
111
+ def entity_id(self) -> EntityId:
112
+ """The entity that owns this queued action."""
113
+ return self._entity_id
114
+
115
+ @property
116
+ def suspendable(self) -> bool:
117
+ """Whether an owner-level suspension request may pause this action."""
118
+ return self._suspendable
119
+
120
+ @property
121
+ def binding(self) -> ActionBinding:
122
+ """Whether this object has entered the queue, and its metadata if so."""
123
+ return self._binding
124
+
125
+ @property
126
+ def seq(self) -> int:
127
+ """Return the permanent sequence number assigned by the store.
128
+
129
+ Raises:
130
+ RuntimeError: If the action has not been admitted yet.
131
+ """
132
+ match self._binding:
133
+ case Bound(seq=seq):
134
+ return seq
135
+ case Unbound():
136
+ raise RuntimeError("an unbound action has no seq")
137
+
138
+ @property
139
+ def state(self) -> ActionState:
140
+ """Return the active or suspended state of an admitted action.
141
+
142
+ Raises:
143
+ RuntimeError: If the action has not been admitted yet.
144
+ """
145
+ match self._binding:
146
+ case Bound(state=state):
147
+ return state
148
+ case Unbound():
149
+ raise RuntimeError("an unbound action has no state")
150
+
151
+ def bind(self, seq: int, state: ActionState, /) -> None:
152
+ """Bind this object after its first successful store admission.
153
+
154
+ Durable stores also use this operation when hydrating an admitted
155
+ action. Calling it twice would replace the replay identity and is
156
+ therefore rejected.
157
+
158
+ Args:
159
+ seq: Permanent sequence number allocated by the store.
160
+ state: Restored active or suspended queue state.
161
+
162
+ Raises:
163
+ RuntimeError: If the action is already bound.
164
+ """
165
+ match self._binding:
166
+ case Unbound():
167
+ self._binding = Bound(seq, state)
168
+ case Bound():
169
+ raise RuntimeError("an action can only be bound once")
170
+
171
+ def reschedule(self, due_tick: Tick | None, /) -> None:
172
+ """Replace an active deadline while preserving the action's `seq`.
173
+
174
+ Args:
175
+ due_tick: New deadline, or `None` for recurring execution.
176
+
177
+ Raises:
178
+ RuntimeError: If the action is unbound or suspended.
179
+ """
180
+ match self._binding:
181
+ case Bound(seq=seq, state=state):
182
+ match state:
183
+ case Active():
184
+ self._binding = Bound(seq, Active(due_tick))
185
+ case Suspended():
186
+ raise RuntimeError("a suspended action cannot be rescheduled")
187
+ case Unbound():
188
+ raise RuntimeError("an unbound action must be bound before rescheduling")
189
+
190
+ def suspend(self, tick: Tick, by: SuspensionId, /) -> None:
191
+ """Pause this action, preserving its deadline and replay identity.
192
+
193
+ Non-suspendable and already-suspended actions are unchanged.
194
+
195
+ Args:
196
+ tick: Tick at which the pause begins.
197
+ by: Opaque handle that owes the later resumption.
198
+
199
+ Raises:
200
+ RuntimeError: If the action has not been admitted yet.
201
+ """
202
+ if not self._suspendable:
203
+ return
204
+
205
+ match self._binding:
206
+ case Bound(seq=seq, state=state):
207
+ match state:
208
+ case Active(due_tick=due_tick):
209
+ self._binding = Bound(
210
+ seq,
211
+ Suspended(
212
+ suspended_at=tick,
213
+ suspended_by=by,
214
+ due_tick=due_tick,
215
+ ),
216
+ )
217
+ case Suspended():
218
+ pass
219
+ case Unbound():
220
+ raise RuntimeError("an unbound action cannot be suspended")
221
+
222
+ def resume(self, tick: Tick, /) -> None:
223
+ """Resume this action and shift its deadlines by the pause length.
224
+
225
+ Active actions are unchanged. For a suspended action, the stored
226
+ deadline is shifted and `on_resume(paused_for)` is called.
227
+
228
+ Args:
229
+ tick: Tick at which the action resumes.
230
+
231
+ Raises:
232
+ RuntimeError: If the action has not been admitted yet.
233
+ """
234
+ match self._binding:
235
+ case Bound(seq=seq, state=state):
236
+ match state:
237
+ case Suspended(suspended_at=suspended_at, due_tick=due_tick):
238
+ paused_for = tick - suspended_at
239
+ self._binding = Bound(
240
+ seq,
241
+ Active(
242
+ due_tick=None if due_tick is None else due_tick + paused_for,
243
+ ),
244
+ )
245
+ self.on_resume(paused_for)
246
+ case Active():
247
+ pass
248
+ case Unbound():
249
+ raise RuntimeError("an unbound action cannot be resumed")
250
+
251
+ def on_resume(self, paused_for: int, /) -> None: # noqa: B027 - optional hook
252
+ """React to resumption after `paused_for` logical ticks.
253
+
254
+ Override this hook to shift game-owned deadlines such as `arrives_at`.
255
+ The default implementation does nothing.
256
+ """
257
+
258
+ @abstractmethod
259
+ def process(self, ctx: TickContext[W], /) -> None:
260
+ """Describe this occurrence through the collecting context.
261
+
262
+ Mutate neither the queue nor persistent world state directly. Use the
263
+ context to stage effects, schedules, suspension, logs, and completion.
264
+ """
265
+ ...
266
+
267
+
268
+ def restore_action_binding[W](action: BaseAction[W], binding: ActionBinding, /) -> None:
269
+ """Restore engine-owned metadata after an in-memory commit failure.
270
+
271
+ Internal to foliot and deliberately absent from `__all__`. Consumer stores
272
+ use their own transaction rollback; only `MemoryStore` needs to restore a
273
+ Python object that was already mutated while publishing staged commands.
274
+ """
275
+ action._binding = binding # pyright: ignore[reportPrivateUsage] -- same-module rollback
foliot/context.py ADDED
@@ -0,0 +1,160 @@
1
+ """Capabilities supplied to action handlers and tick finalizers.
2
+
3
+ `TickContext` is deliberately narrow: two things to read and five things to say.
4
+ It is the one object every handler touches, which makes it the one object most
5
+ likely to rot into a service locator -- a `ctx` that can reach everything, used
6
+ by handlers to reach anything. Treat any proposal to add `ctx.store`,
7
+ `ctx.world` or `ctx.entity` as the alarm rather than the feature.
8
+
9
+ The discipline that keeps it contained: **pass the capability, not the
10
+ context.** A handler that needs a die roll downstream passes `ctx.rng`, never
11
+ `ctx`. Then nothing below `process()` can reach the scheduler, so nothing below
12
+ `process()` can schedule.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Protocol
18
+
19
+ from foliot.effects import Effect
20
+ from foliot.ids import EntityId, SuspensionId, Tick
21
+ from foliot.rng import Rng
22
+
23
+ if TYPE_CHECKING:
24
+ from foliot.actions import BaseAction
25
+ from foliot.events import EventId
26
+
27
+ __all__ = ["FinalizationContext", "TickContext", "TickFinalizer"]
28
+
29
+
30
+ class TickContext[W](Protocol):
31
+ """Reads the tick; collects what the handler wants to happen.
32
+
33
+ Nothing said to a `TickContext` takes effect when it is said. The engine
34
+ gives each action a fresh context, and:
35
+
36
+ - the handler returns normally -> its collected work joins the tick's pile
37
+ - the handler raises -> its context is discarded whole, nothing
38
+ it said happens, and the engine skips it and carries on
39
+
40
+ Only once every due action has been asked does the engine drain the pile:
41
+ schedules and suspension requests into the queue, effects applied, log
42
+ lines written. No action can therefore observe another action's effects
43
+ from the same tick.
44
+
45
+ `tick` and `rng` are read-only properties rather than plain annotations: a
46
+ Protocol written `tick: Tick` demands something *settable*, which a real
47
+ `Simulation` -- whose tick is computed from the store so that nothing can
48
+ assign the world's clock -- would fail to satisfy.
49
+ """
50
+
51
+ @property
52
+ def tick(self) -> Tick: ...
53
+
54
+ @property
55
+ def rng(self) -> Rng:
56
+ """Already bound to `(world_seed, entity_id, tick, seq)`. Never seed."""
57
+ ...
58
+
59
+ def emit(self, effect: Effect[W], /) -> None:
60
+ """Stage a game-defined world mutation for the apply phase.
61
+
62
+ Args:
63
+ effect: Object whose `apply(world)` method performs the mutation.
64
+ """
65
+ ...
66
+
67
+ def schedule(self, action: BaseAction[W], due_tick: Tick | None, /) -> None:
68
+ """Queue a new action or reschedule an active one.
69
+
70
+ Args:
71
+ action: Action object to admit or reschedule.
72
+ due_tick: Strictly future deadline, or `None` for execution on
73
+ every tick.
74
+
75
+ Raises:
76
+ TypeError: If a deadline is not an integer or `None`.
77
+ ValueError: If a concrete deadline is not later than `tick`.
78
+ """
79
+ ...
80
+
81
+ def log(self, line: str, /) -> None:
82
+ """Write one line of narrative for the observer.
83
+
84
+ Lines are written in permanent action-sequence order rather than store
85
+ iteration order.
86
+
87
+ Args:
88
+ line: Complete game-facing narrative line. Foliot does not format
89
+ or localize it.
90
+ """
91
+ ...
92
+
93
+ def suspend(
94
+ self,
95
+ entity_id: EntityId,
96
+ /,
97
+ *,
98
+ by: SuspensionId | EventId,
99
+ ) -> None:
100
+ """Pause an entity's suspendable actions under one waking handle.
101
+
102
+ Args:
103
+ entity_id: Owner whose suspendable actions should pause.
104
+ by: Stable handle used for the later matching resume.
105
+ """
106
+ ...
107
+
108
+ def finish(self) -> None:
109
+ """This action is done; remove it from the queue.
110
+
111
+ A scheduled action can stop simply by not rescheduling. A recurring one
112
+ has no deadline to decline, so it must say so.
113
+ """
114
+ ...
115
+
116
+
117
+ class FinalizationContext[W](Protocol):
118
+ """Collect lifecycle work after all normal effects have been applied.
119
+
120
+ It deliberately has no RNG or world property. The game receives the
121
+ post-effect world as the other argument to `TickFinalizer.finalize`, while
122
+ every requested write still travels through this collecting boundary.
123
+ """
124
+
125
+ @property
126
+ def tick(self) -> Tick:
127
+ """Logical tick currently being finalized."""
128
+ ...
129
+
130
+ def emit(self, effect: Effect[W], /) -> None:
131
+ """Stage one final effect for application in this transaction."""
132
+ ...
133
+
134
+ def schedule(self, action: BaseAction[W], due_tick: Tick | None, /) -> None:
135
+ """Stage an action for a future tick, or as recurring work."""
136
+ ...
137
+
138
+ def delete(self, action: BaseAction[W], /) -> None:
139
+ """Remove one action from all future due snapshots."""
140
+ ...
141
+
142
+ def delete_owned_by(self, entity_id: EntityId, /) -> None:
143
+ """Remove all actions owned by one entity."""
144
+ ...
145
+
146
+ def log(self, line: str, /) -> None:
147
+ """Append one deterministic journal line for this tick."""
148
+ ...
149
+
150
+
151
+ class TickFinalizer[W](Protocol):
152
+ """Optional game-owned policy run after a tick's normal effects.
153
+
154
+ Use a finalizer for rules that depend on the combined post-effect world,
155
+ such as death or cleanup. A raised exception aborts the tick transaction.
156
+ """
157
+
158
+ def finalize(self, world: W, ctx: FinalizationContext[W], /) -> None:
159
+ """Inspect `world` and stage any final lifecycle work in `ctx`."""
160
+ ...
foliot/drivers.py ADDED
@@ -0,0 +1,172 @@
1
+ """Pacing strategies for the simulation loop.
2
+
3
+ `process_tick()` never waits. That split is the whole reason the same code path
4
+ runs at one tick per second in production and at ten million ticks per second in
5
+ a test: only the driver differs. Hardcoding `time.sleep` into the loop makes the
6
+ library untestable, and it is felt immediately.
7
+
8
+ `ManualDriver` advances without waiting. `RealtimeDriver` paces the same loop
9
+ against a monotonic, absolute cadence and skips wall-clock slots after an
10
+ overrun without skipping logical ticks.
11
+ """
12
+
13
+ import logging
14
+ import math
15
+ import time
16
+ from dataclasses import dataclass
17
+ from typing import Protocol
18
+
19
+ from foliot.ids import Tick
20
+
21
+ __all__ = ["Driver", "ManualDriver", "RealtimeDriver"]
22
+
23
+ _LOGGER = logging.getLogger(__name__)
24
+
25
+
26
+ class Driver(Protocol):
27
+ """Structural pacing contract consumed by `Simulation.run()`."""
28
+
29
+ def wait_for(self, tick: Tick, /) -> None:
30
+ """Block until `tick` should begin.
31
+
32
+ `ManualDriver` returns at once. `RealtimeDriver` sleeps toward an
33
+ absolute cadence target -- `start + slot * duration` -- never a
34
+ relative one. Cadence slots may be skipped after an overrun; logical
35
+ ticks may not.
36
+ """
37
+ ...
38
+
39
+ def should_continue(self, tick: Tick, /) -> bool:
40
+ """Whether the next unfinished `tick` should be processed."""
41
+ ...
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class ManualDriver:
46
+ """Run immediately through an inclusive target tick.
47
+
48
+ Args:
49
+ until_tick: Last logical tick to process. Must be non-negative.
50
+
51
+ Raises:
52
+ TypeError: If `until_tick` is not an integer or is a boolean.
53
+ ValueError: If `until_tick` is negative.
54
+ """
55
+
56
+ until_tick: Tick
57
+
58
+ def __post_init__(self) -> None:
59
+ if type(self.until_tick) is not int:
60
+ raise TypeError("until_tick must be an int, not bool")
61
+ if self.until_tick < 0:
62
+ raise ValueError("until_tick must be non-negative")
63
+
64
+ def wait_for(self, tick: Tick, /) -> None:
65
+ """Return immediately; manual time never sleeps."""
66
+ del tick
67
+
68
+ def should_continue(self, tick: Tick, /) -> bool:
69
+ """Include `until_tick`, then stop at the following tick."""
70
+ return tick <= self.until_tick
71
+
72
+
73
+ class RealtimeDriver:
74
+ """Run continuously on a fixed monotonic cadence.
75
+
76
+ The object is intentionally stateful: it remembers one run's cadence
77
+ anchor, current wall-clock slot, and most recently started logical tick.
78
+ Create a new driver to establish a fresh cadence after restart.
79
+
80
+ Args:
81
+ tick_seconds: Positive finite number of seconds between cadence slots.
82
+ Whole and fractional values are supported.
83
+
84
+ Raises:
85
+ TypeError: If `tick_seconds` is not an integer or float, or is a
86
+ boolean.
87
+ ValueError: If it is zero, negative, infinite, NaN, or too large to
88
+ represent as a finite float.
89
+ """
90
+
91
+ __slots__ = (
92
+ "_cadence_start",
93
+ "_slot",
94
+ "_started_at",
95
+ "_started_tick",
96
+ "_tick_seconds",
97
+ )
98
+
99
+ def __init__(self, tick_seconds: float) -> None:
100
+ if type(tick_seconds) not in (int, float):
101
+ raise TypeError("tick_seconds must be an int or float, not bool")
102
+ try:
103
+ normalized = float(tick_seconds)
104
+ except OverflowError as error:
105
+ raise ValueError("tick_seconds must be finite and greater than zero") from error
106
+ if not math.isfinite(normalized) or normalized <= 0.0:
107
+ raise ValueError("tick_seconds must be finite and greater than zero")
108
+
109
+ self._tick_seconds = normalized
110
+ self._cadence_start: float | None = None
111
+ self._slot = 0
112
+ self._started_at: float | None = None
113
+ self._started_tick: Tick | None = None
114
+
115
+ @property
116
+ def tick_seconds(self) -> float:
117
+ """Seconds between wall-clock cadence slots."""
118
+ return self._tick_seconds
119
+
120
+ def wait_for(self, tick: Tick, /) -> None:
121
+ """Wait until `tick` may begin on this run's absolute cadence."""
122
+ now = self._now()
123
+ if self._cadence_start is None:
124
+ self._cadence_start = now
125
+ self._started_at = now
126
+ self._started_tick = tick
127
+ return
128
+
129
+ cadence_start = self._cadence_start
130
+ started_at = self._started_at
131
+ started_tick = self._started_tick
132
+ assert started_at is not None
133
+ assert started_tick is not None
134
+
135
+ next_slot = self._slot + 1
136
+ slot = max(next_slot, math.ceil((now - cadence_start) / self._tick_seconds))
137
+ deadline = cadence_start + slot * self._tick_seconds
138
+ while deadline < now:
139
+ slot += 1
140
+ deadline = cadence_start + slot * self._tick_seconds
141
+
142
+ missed_slots = slot - next_slot
143
+ if missed_slots:
144
+ _LOGGER.warning(
145
+ "realtime tick overran cadence: tick=%s processing_seconds=%.9g "
146
+ "tick_seconds=%.9g missed_slots=%s",
147
+ started_tick,
148
+ now - started_at,
149
+ self._tick_seconds,
150
+ missed_slots,
151
+ )
152
+
153
+ remaining = deadline - now
154
+ if remaining > 0.0:
155
+ self._sleep(remaining)
156
+
157
+ self._slot = slot
158
+ self._started_at = self._now()
159
+ self._started_tick = tick
160
+
161
+ def should_continue(self, tick: Tick, /) -> bool:
162
+ """Run until the surrounding application interrupts the loop."""
163
+ del tick
164
+ return True
165
+
166
+ def _now(self) -> float:
167
+ """Return monotonic time; overridden only by foliot's private tests."""
168
+ return time.monotonic()
169
+
170
+ def _sleep(self, seconds: float, /) -> None:
171
+ """Sleep in real time; overridden only by foliot's private tests."""
172
+ time.sleep(seconds)