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 +61 -0
- foliot/_event_bridge.py +7 -0
- foliot/actions.py +275 -0
- foliot/context.py +160 -0
- foliot/drivers.py +172 -0
- foliot/effects.py +26 -0
- foliot/engine.py +565 -0
- foliot/events/__init__.py +43 -0
- foliot/events/_api.py +675 -0
- foliot/events/memory.py +218 -0
- foliot/ids.py +26 -0
- foliot/py.typed +0 -0
- foliot/rng.py +178 -0
- foliot/stores/__init__.py +133 -0
- foliot/stores/memory.py +493 -0
- foliot-0.1.0.dist-info/METADATA +196 -0
- foliot-0.1.0.dist-info/RECORD +19 -0
- foliot-0.1.0.dist-info/WHEEL +4 -0
- foliot-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
+
]
|
foliot/_event_bridge.py
ADDED
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)
|