foliot 0.1.0__tar.gz
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-0.1.0/CHANGELOG.md +29 -0
- foliot-0.1.0/CONTRIBUTING.md +75 -0
- foliot-0.1.0/LICENSE +21 -0
- foliot-0.1.0/PKG-INFO +196 -0
- foliot-0.1.0/README.md +169 -0
- foliot-0.1.0/RELEASING.md +49 -0
- foliot-0.1.0/docs/actions.md +98 -0
- foliot-0.1.0/docs/api.md +42 -0
- foliot-0.1.0/docs/architecture.md +97 -0
- foliot-0.1.0/docs/determinism.md +58 -0
- foliot-0.1.0/docs/drivers.md +37 -0
- foliot-0.1.0/docs/events.md +181 -0
- foliot-0.1.0/docs/index.md +19 -0
- foliot-0.1.0/docs/quickstart.md +104 -0
- foliot-0.1.0/docs/stores.md +148 -0
- foliot-0.1.0/examples/__init__.py +1 -0
- foliot-0.1.0/examples/eventworld/README.md +30 -0
- foliot-0.1.0/examples/eventworld/__init__.py +453 -0
- foliot-0.1.0/examples/eventworld/__main__.py +31 -0
- foliot-0.1.0/examples/tinyworld/README.md +27 -0
- foliot-0.1.0/examples/tinyworld/__init__.py +346 -0
- foliot-0.1.0/examples/tinyworld/__main__.py +33 -0
- foliot-0.1.0/pyproject.toml +118 -0
- foliot-0.1.0/pyproject.toml.orig +91 -0
- foliot-0.1.0/src/foliot/__init__.py +61 -0
- foliot-0.1.0/src/foliot/_event_bridge.py +7 -0
- foliot-0.1.0/src/foliot/actions.py +275 -0
- foliot-0.1.0/src/foliot/context.py +160 -0
- foliot-0.1.0/src/foliot/drivers.py +172 -0
- foliot-0.1.0/src/foliot/effects.py +26 -0
- foliot-0.1.0/src/foliot/engine.py +565 -0
- foliot-0.1.0/src/foliot/events/__init__.py +43 -0
- foliot-0.1.0/src/foliot/events/_api.py +675 -0
- foliot-0.1.0/src/foliot/events/memory.py +218 -0
- foliot-0.1.0/src/foliot/ids.py +26 -0
- foliot-0.1.0/src/foliot/py.typed +0 -0
- foliot-0.1.0/src/foliot/rng.py +178 -0
- foliot-0.1.0/src/foliot/stores/__init__.py +133 -0
- foliot-0.1.0/src/foliot/stores/memory.py +493 -0
- foliot-0.1.0/tests/test_actions.py +85 -0
- foliot-0.1.0/tests/test_drivers.py +166 -0
- foliot-0.1.0/tests/test_engine.py +413 -0
- foliot-0.1.0/tests/test_events.py +820 -0
- foliot-0.1.0/tests/test_eventworld.py +33 -0
- foliot-0.1.0/tests/test_memory_store.py +352 -0
- foliot-0.1.0/tests/test_rng.py +136 -0
- foliot-0.1.0/tests/test_tinyworld.py +28 -0
- foliot-0.1.0/uv.lock +138 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to foliot will be documented here.
|
|
4
|
+
|
|
5
|
+
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and releases use [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.0] - 2026-09-05
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Mandatory `BaseAction` lifecycle with stable admission identity.
|
|
15
|
+
- Scheduled and recurring actions.
|
|
16
|
+
- Suspension, exact deadline shifting, and the `on_resume` hook.
|
|
17
|
+
- Counter-based deterministic random streams and secure world-seed creation.
|
|
18
|
+
- Structural `Store` and `Txn` protocols.
|
|
19
|
+
- Dependency-free `MemoryStore` reference implementation.
|
|
20
|
+
- Atomic deterministic `Simulation` tick processing.
|
|
21
|
+
- Inclusive `ManualDriver` and drift-resistant `RealtimeDriver`.
|
|
22
|
+
- Optional post-effect `TickFinalizer` and owner-wide action deletion.
|
|
23
|
+
- Optional `foliot.events` layer for simultaneous participant Intents.
|
|
24
|
+
- `EventMemoryStore`, stable Event/entity id templates, and explicit Event
|
|
25
|
+
continuation and ending.
|
|
26
|
+
- Runnable Layer-1 Tinyworld and Layer-2 Eventworld examples.
|
|
27
|
+
|
|
28
|
+
[Unreleased]: https://github.com/wordedword75049/foliot/compare/v0.1.0...HEAD
|
|
29
|
+
[0.1.0]: https://github.com/wordedword75049/foliot/releases/tag/v0.1.0
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Contributing to foliot
|
|
2
|
+
|
|
3
|
+
Thank you for helping improve foliot. The project favors small, explicit
|
|
4
|
+
changes that preserve deterministic behavior and keep game concepts outside
|
|
5
|
+
the engine.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
Install [uv](https://docs.astral.sh/uv/), then run:
|
|
10
|
+
|
|
11
|
+
```console
|
|
12
|
+
uv sync
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The project supports Python 3.12 and newer. Runtime dependencies must remain
|
|
16
|
+
empty unless a new dependency has been discussed and justified first.
|
|
17
|
+
|
|
18
|
+
## Required checks
|
|
19
|
+
|
|
20
|
+
```console
|
|
21
|
+
uv run ruff format --check src tests examples
|
|
22
|
+
uv run ruff check src tests examples
|
|
23
|
+
uv run basedpyright
|
|
24
|
+
uv run pytest
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Use `uv run ruff format` to apply formatting.
|
|
28
|
+
|
|
29
|
+
## Design rules
|
|
30
|
+
|
|
31
|
+
- Every queued game action inherits `BaseAction`.
|
|
32
|
+
- External ports such as stores, effects, contexts, RNGs, and drivers use
|
|
33
|
+
structural `Protocol` typing.
|
|
34
|
+
- Keep public contexts narrow. Pass `ctx.rng` to a helper instead of passing
|
|
35
|
+
the whole context.
|
|
36
|
+
- Game nouns do not belong in foliot. The engine has no built-in HP, character,
|
|
37
|
+
target, location, or combat model.
|
|
38
|
+
- Use PEP 695 generic syntax; the supported floor is Python 3.12.
|
|
39
|
+
- Do not use `Any` in public signatures.
|
|
40
|
+
- Use exhaustive `match` statements for closed state unions; do not add a
|
|
41
|
+
catch-all branch that disables type-checker exhaustiveness.
|
|
42
|
+
- Use `logging.getLogger(__name__)`; library code must never call
|
|
43
|
+
`logging.basicConfig()` or print.
|
|
44
|
+
- Do not use Python's `random` module outside `rng.py`, `hash()` for persistent
|
|
45
|
+
string identity, or wall-clock time outside a driver.
|
|
46
|
+
- Keep the root and `foliot.events` export lists curated. A public import path
|
|
47
|
+
is a compatibility promise.
|
|
48
|
+
|
|
49
|
+
## Tests
|
|
50
|
+
|
|
51
|
+
Tests use handwritten fakes rather than `unittest.mock`, `pytest-mock`,
|
|
52
|
+
monkeypatching production objects, or time-freezing libraries.
|
|
53
|
+
|
|
54
|
+
Important changes should prove the architectural property they affect:
|
|
55
|
+
|
|
56
|
+
- replay produces the same output from the same seed;
|
|
57
|
+
- reversing due-action order does not change results;
|
|
58
|
+
- suspension shifts deadlines by the exact pause duration;
|
|
59
|
+
- exceptional transaction exit publishes no foliot-owned state;
|
|
60
|
+
- an incomplete Event round retains exactly one copy of every current child;
|
|
61
|
+
- Layer 1 behaves identically when the optional Event layer is unused.
|
|
62
|
+
|
|
63
|
+
Test game actions directly with a tiny recording context whenever possible.
|
|
64
|
+
Use a full `Simulation` only when testing engine coordination.
|
|
65
|
+
|
|
66
|
+
## Documentation
|
|
67
|
+
|
|
68
|
+
Public docstrings explain behavior, parameters, return values, and meaningful
|
|
69
|
+
exceptions. Examples should show normal usage. Internal comments should explain
|
|
70
|
+
why a non-obvious mechanism exists rather than narrating the next line of code.
|
|
71
|
+
|
|
72
|
+
Update the relevant guide whenever a public contract changes.
|
|
73
|
+
|
|
74
|
+
Maintainers should follow [RELEASING.md](RELEASING.md) for versioning, artifact
|
|
75
|
+
checks, and trusted publication.
|
foliot-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nikita Ivanin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
foliot-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: foliot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A deterministic tick-driven simulation core: durable queue, absolute deadlines, per-entity randomness.
|
|
5
|
+
Keywords: simulation,scheduler,tick,deterministic,event-queue
|
|
6
|
+
Author: Nikita Ivanin
|
|
7
|
+
Author-email: Nikita Ivanin <naik.iva@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
17
|
+
Classifier: Topic :: Games/Entertainment :: Simulation
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.12
|
|
21
|
+
Project-URL: Homepage, https://github.com/wordedword75049/foliot
|
|
22
|
+
Project-URL: Documentation, https://github.com/wordedword75049/foliot/blob/main/docs/index.md
|
|
23
|
+
Project-URL: Repository, https://github.com/wordedword75049/foliot.git
|
|
24
|
+
Project-URL: Issues, https://github.com/wordedword75049/foliot/issues
|
|
25
|
+
Project-URL: Changelog, https://github.com/wordedword75049/foliot/blob/main/CHANGELOG.md
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# foliot
|
|
29
|
+
|
|
30
|
+
`foliot` is a small, deterministic simulation engine for worlds that advance
|
|
31
|
+
in logical ticks.
|
|
32
|
+
|
|
33
|
+
It gives you a durable action queue, scheduled and recurring work, reproducible
|
|
34
|
+
randomness, suspension and resumption, atomic ticks, real-time pacing, and an
|
|
35
|
+
optional layer for simultaneous multi-entity events. It deliberately does not
|
|
36
|
+
define characters, maps, combat, health, inventories, or quests. Those belong
|
|
37
|
+
to your simulation.
|
|
38
|
+
|
|
39
|
+
> **Status:** pre-alpha. The core is tested and usable, but the public API may
|
|
40
|
+
> still change before 1.0.
|
|
41
|
+
|
|
42
|
+
## Why foliot?
|
|
43
|
+
|
|
44
|
+
- **Reproducible:** the same world seed and inputs produce the same history.
|
|
45
|
+
- **Order-independent:** unrelated actions have independent random streams.
|
|
46
|
+
- **Transactional:** queue changes, effects, journal lines, and the tick commit
|
|
47
|
+
through one store transaction.
|
|
48
|
+
- **Fast-forwardable:** use logical time to run millions of ticks without
|
|
49
|
+
sleeping.
|
|
50
|
+
- **Domain-agnostic:** your game owns its world model and rules.
|
|
51
|
+
- **Storage-agnostic:** implement two small protocols for PostgreSQL, MySQL,
|
|
52
|
+
MariaDB, a file, or another backend.
|
|
53
|
+
- **Dependency-free:** the installed library uses only the Python standard
|
|
54
|
+
library.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```console
|
|
59
|
+
pip install foliot
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Python 3.12 or newer is required.
|
|
63
|
+
|
|
64
|
+
## Five-minute example
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from dataclasses import dataclass
|
|
68
|
+
from typing import override
|
|
69
|
+
|
|
70
|
+
from foliot import BaseAction, EntityId, ManualDriver, MemoryStore, Simulation, TickContext
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(slots=True)
|
|
74
|
+
class World:
|
|
75
|
+
energy: int = 100
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True, slots=True)
|
|
79
|
+
class LoseEnergy:
|
|
80
|
+
amount: int
|
|
81
|
+
|
|
82
|
+
def apply(self, world: World, /) -> None:
|
|
83
|
+
world.energy -= self.amount
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Hunger(BaseAction[World]):
|
|
87
|
+
def __init__(self, entity_id: EntityId) -> None:
|
|
88
|
+
super().__init__(entity_id, suspendable=False)
|
|
89
|
+
|
|
90
|
+
@override
|
|
91
|
+
def process(self, ctx: TickContext[World], /) -> None:
|
|
92
|
+
ctx.emit(LoseEnergy(1))
|
|
93
|
+
ctx.log("Lira grows hungry.")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
world = World()
|
|
97
|
+
store = MemoryStore(
|
|
98
|
+
world,
|
|
99
|
+
world_seed=1,
|
|
100
|
+
initial_actions=((Hunger(EntityId("lira")), None),),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
Simulation(store).run(ManualDriver(until_tick=9))
|
|
104
|
+
|
|
105
|
+
assert world.energy == 90
|
|
106
|
+
assert len(store.logs) == 10
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`due_tick=None` makes `Hunger` recurring, so it runs once per logical tick.
|
|
110
|
+
Effects and journal lines are collected first and applied only after every due
|
|
111
|
+
action has made its decision.
|
|
112
|
+
|
|
113
|
+
## The model
|
|
114
|
+
|
|
115
|
+
An action begins **unbound**. Its first successful store admission assigns one
|
|
116
|
+
permanent sequence number and an active state. Scheduling the same object again
|
|
117
|
+
changes its deadline without changing that identity.
|
|
118
|
+
|
|
119
|
+
| Request | Meaning |
|
|
120
|
+
|---|---|
|
|
121
|
+
| `ctx.schedule(action, 100)` | Run at tick 100. |
|
|
122
|
+
| `ctx.schedule(action, None)` | Run every tick until `ctx.finish()`. |
|
|
123
|
+
| `ctx.emit(effect)` | Apply a game-defined world change after decisions. |
|
|
124
|
+
| `ctx.log(line)` | Append deterministic narrative output. |
|
|
125
|
+
| `ctx.suspend(entity_id, by=handle)` | Pause that entity's suspendable actions. |
|
|
126
|
+
| `ctx.finish()` | Remove the current action. |
|
|
127
|
+
|
|
128
|
+
Concrete deadlines must always be later than the current tick. An action due
|
|
129
|
+
at tick 90 can reschedule itself for tick 100; it keeps the same sequence
|
|
130
|
+
number.
|
|
131
|
+
|
|
132
|
+
## Optional simultaneous Events
|
|
133
|
+
|
|
134
|
+
Ordinary actions are enough for walking, hunger, poison, construction, growth,
|
|
135
|
+
and most other simulation work. Import `foliot.events` only when several
|
|
136
|
+
entities must decide from the same tick-start state before any result applies.
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from foliot import Simulation
|
|
140
|
+
from foliot.events import EventMemoryStore, Events
|
|
141
|
+
|
|
142
|
+
store = EventMemoryStore(world, world_seed=1)
|
|
143
|
+
simulation = Simulation(store, events=Events(store))
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Each `EventAction` returns one game-defined Intent. Once every expected
|
|
147
|
+
participant has answered in the same tick, the concrete `BaseEvent` resolves
|
|
148
|
+
the complete set into either `Outcome.continue_with(...)` or `Outcome.end(...)`.
|
|
149
|
+
Combat formulas and lifecycle rules remain game code.
|
|
150
|
+
|
|
151
|
+
Run the complete example:
|
|
152
|
+
|
|
153
|
+
```console
|
|
154
|
+
uv run python -m examples.eventworld
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Storage
|
|
158
|
+
|
|
159
|
+
`MemoryStore` and `EventMemoryStore` are ready-made implementations for tests,
|
|
160
|
+
examples, and temporary simulations. They disappear with the process.
|
|
161
|
+
|
|
162
|
+
A durable application implements `Store` and `Txn` (plus `EventStore` and
|
|
163
|
+
`EventTxn` when Events are enabled). Foliot decides *when* the transaction
|
|
164
|
+
begins and commits; your adapter decides *how* actions and world state are
|
|
165
|
+
encoded in its database.
|
|
166
|
+
|
|
167
|
+
See [Writing a store](https://github.com/wordedword75049/foliot/blob/main/docs/stores.md)
|
|
168
|
+
for the complete contract and a
|
|
169
|
+
PostgreSQL-shaped example.
|
|
170
|
+
|
|
171
|
+
## Documentation
|
|
172
|
+
|
|
173
|
+
- [Documentation index](https://github.com/wordedword75049/foliot/blob/main/docs/index.md)
|
|
174
|
+
- [Quickstart](https://github.com/wordedword75049/foliot/blob/main/docs/quickstart.md)
|
|
175
|
+
- [Actions and effects](https://github.com/wordedword75049/foliot/blob/main/docs/actions.md)
|
|
176
|
+
- [Simultaneous Events](https://github.com/wordedword75049/foliot/blob/main/docs/events.md)
|
|
177
|
+
- [Writing a durable store](https://github.com/wordedword75049/foliot/blob/main/docs/stores.md)
|
|
178
|
+
- [Determinism and randomness](https://github.com/wordedword75049/foliot/blob/main/docs/determinism.md)
|
|
179
|
+
- [Manual and real-time drivers](https://github.com/wordedword75049/foliot/blob/main/docs/drivers.md)
|
|
180
|
+
- [Architecture](https://github.com/wordedword75049/foliot/blob/main/docs/architecture.md)
|
|
181
|
+
- [Public API map](https://github.com/wordedword75049/foliot/blob/main/docs/api.md)
|
|
182
|
+
- [Contributing](https://github.com/wordedword75049/foliot/blob/main/CONTRIBUTING.md)
|
|
183
|
+
|
|
184
|
+
## Development
|
|
185
|
+
|
|
186
|
+
```console
|
|
187
|
+
uv sync
|
|
188
|
+
uv run ruff format --check src tests examples
|
|
189
|
+
uv run ruff check src tests examples
|
|
190
|
+
uv run basedpyright
|
|
191
|
+
uv run pytest
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
MIT
|
foliot-0.1.0/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# foliot
|
|
2
|
+
|
|
3
|
+
`foliot` is a small, deterministic simulation engine for worlds that advance
|
|
4
|
+
in logical ticks.
|
|
5
|
+
|
|
6
|
+
It gives you a durable action queue, scheduled and recurring work, reproducible
|
|
7
|
+
randomness, suspension and resumption, atomic ticks, real-time pacing, and an
|
|
8
|
+
optional layer for simultaneous multi-entity events. It deliberately does not
|
|
9
|
+
define characters, maps, combat, health, inventories, or quests. Those belong
|
|
10
|
+
to your simulation.
|
|
11
|
+
|
|
12
|
+
> **Status:** pre-alpha. The core is tested and usable, but the public API may
|
|
13
|
+
> still change before 1.0.
|
|
14
|
+
|
|
15
|
+
## Why foliot?
|
|
16
|
+
|
|
17
|
+
- **Reproducible:** the same world seed and inputs produce the same history.
|
|
18
|
+
- **Order-independent:** unrelated actions have independent random streams.
|
|
19
|
+
- **Transactional:** queue changes, effects, journal lines, and the tick commit
|
|
20
|
+
through one store transaction.
|
|
21
|
+
- **Fast-forwardable:** use logical time to run millions of ticks without
|
|
22
|
+
sleeping.
|
|
23
|
+
- **Domain-agnostic:** your game owns its world model and rules.
|
|
24
|
+
- **Storage-agnostic:** implement two small protocols for PostgreSQL, MySQL,
|
|
25
|
+
MariaDB, a file, or another backend.
|
|
26
|
+
- **Dependency-free:** the installed library uses only the Python standard
|
|
27
|
+
library.
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```console
|
|
32
|
+
pip install foliot
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Python 3.12 or newer is required.
|
|
36
|
+
|
|
37
|
+
## Five-minute example
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from dataclasses import dataclass
|
|
41
|
+
from typing import override
|
|
42
|
+
|
|
43
|
+
from foliot import BaseAction, EntityId, ManualDriver, MemoryStore, Simulation, TickContext
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(slots=True)
|
|
47
|
+
class World:
|
|
48
|
+
energy: int = 100
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True, slots=True)
|
|
52
|
+
class LoseEnergy:
|
|
53
|
+
amount: int
|
|
54
|
+
|
|
55
|
+
def apply(self, world: World, /) -> None:
|
|
56
|
+
world.energy -= self.amount
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Hunger(BaseAction[World]):
|
|
60
|
+
def __init__(self, entity_id: EntityId) -> None:
|
|
61
|
+
super().__init__(entity_id, suspendable=False)
|
|
62
|
+
|
|
63
|
+
@override
|
|
64
|
+
def process(self, ctx: TickContext[World], /) -> None:
|
|
65
|
+
ctx.emit(LoseEnergy(1))
|
|
66
|
+
ctx.log("Lira grows hungry.")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
world = World()
|
|
70
|
+
store = MemoryStore(
|
|
71
|
+
world,
|
|
72
|
+
world_seed=1,
|
|
73
|
+
initial_actions=((Hunger(EntityId("lira")), None),),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
Simulation(store).run(ManualDriver(until_tick=9))
|
|
77
|
+
|
|
78
|
+
assert world.energy == 90
|
|
79
|
+
assert len(store.logs) == 10
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`due_tick=None` makes `Hunger` recurring, so it runs once per logical tick.
|
|
83
|
+
Effects and journal lines are collected first and applied only after every due
|
|
84
|
+
action has made its decision.
|
|
85
|
+
|
|
86
|
+
## The model
|
|
87
|
+
|
|
88
|
+
An action begins **unbound**. Its first successful store admission assigns one
|
|
89
|
+
permanent sequence number and an active state. Scheduling the same object again
|
|
90
|
+
changes its deadline without changing that identity.
|
|
91
|
+
|
|
92
|
+
| Request | Meaning |
|
|
93
|
+
|---|---|
|
|
94
|
+
| `ctx.schedule(action, 100)` | Run at tick 100. |
|
|
95
|
+
| `ctx.schedule(action, None)` | Run every tick until `ctx.finish()`. |
|
|
96
|
+
| `ctx.emit(effect)` | Apply a game-defined world change after decisions. |
|
|
97
|
+
| `ctx.log(line)` | Append deterministic narrative output. |
|
|
98
|
+
| `ctx.suspend(entity_id, by=handle)` | Pause that entity's suspendable actions. |
|
|
99
|
+
| `ctx.finish()` | Remove the current action. |
|
|
100
|
+
|
|
101
|
+
Concrete deadlines must always be later than the current tick. An action due
|
|
102
|
+
at tick 90 can reschedule itself for tick 100; it keeps the same sequence
|
|
103
|
+
number.
|
|
104
|
+
|
|
105
|
+
## Optional simultaneous Events
|
|
106
|
+
|
|
107
|
+
Ordinary actions are enough for walking, hunger, poison, construction, growth,
|
|
108
|
+
and most other simulation work. Import `foliot.events` only when several
|
|
109
|
+
entities must decide from the same tick-start state before any result applies.
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
from foliot import Simulation
|
|
113
|
+
from foliot.events import EventMemoryStore, Events
|
|
114
|
+
|
|
115
|
+
store = EventMemoryStore(world, world_seed=1)
|
|
116
|
+
simulation = Simulation(store, events=Events(store))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Each `EventAction` returns one game-defined Intent. Once every expected
|
|
120
|
+
participant has answered in the same tick, the concrete `BaseEvent` resolves
|
|
121
|
+
the complete set into either `Outcome.continue_with(...)` or `Outcome.end(...)`.
|
|
122
|
+
Combat formulas and lifecycle rules remain game code.
|
|
123
|
+
|
|
124
|
+
Run the complete example:
|
|
125
|
+
|
|
126
|
+
```console
|
|
127
|
+
uv run python -m examples.eventworld
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Storage
|
|
131
|
+
|
|
132
|
+
`MemoryStore` and `EventMemoryStore` are ready-made implementations for tests,
|
|
133
|
+
examples, and temporary simulations. They disappear with the process.
|
|
134
|
+
|
|
135
|
+
A durable application implements `Store` and `Txn` (plus `EventStore` and
|
|
136
|
+
`EventTxn` when Events are enabled). Foliot decides *when* the transaction
|
|
137
|
+
begins and commits; your adapter decides *how* actions and world state are
|
|
138
|
+
encoded in its database.
|
|
139
|
+
|
|
140
|
+
See [Writing a store](https://github.com/wordedword75049/foliot/blob/main/docs/stores.md)
|
|
141
|
+
for the complete contract and a
|
|
142
|
+
PostgreSQL-shaped example.
|
|
143
|
+
|
|
144
|
+
## Documentation
|
|
145
|
+
|
|
146
|
+
- [Documentation index](https://github.com/wordedword75049/foliot/blob/main/docs/index.md)
|
|
147
|
+
- [Quickstart](https://github.com/wordedword75049/foliot/blob/main/docs/quickstart.md)
|
|
148
|
+
- [Actions and effects](https://github.com/wordedword75049/foliot/blob/main/docs/actions.md)
|
|
149
|
+
- [Simultaneous Events](https://github.com/wordedword75049/foliot/blob/main/docs/events.md)
|
|
150
|
+
- [Writing a durable store](https://github.com/wordedword75049/foliot/blob/main/docs/stores.md)
|
|
151
|
+
- [Determinism and randomness](https://github.com/wordedword75049/foliot/blob/main/docs/determinism.md)
|
|
152
|
+
- [Manual and real-time drivers](https://github.com/wordedword75049/foliot/blob/main/docs/drivers.md)
|
|
153
|
+
- [Architecture](https://github.com/wordedword75049/foliot/blob/main/docs/architecture.md)
|
|
154
|
+
- [Public API map](https://github.com/wordedword75049/foliot/blob/main/docs/api.md)
|
|
155
|
+
- [Contributing](https://github.com/wordedword75049/foliot/blob/main/CONTRIBUTING.md)
|
|
156
|
+
|
|
157
|
+
## Development
|
|
158
|
+
|
|
159
|
+
```console
|
|
160
|
+
uv sync
|
|
161
|
+
uv run ruff format --check src tests examples
|
|
162
|
+
uv run ruff check src tests examples
|
|
163
|
+
uv run basedpyright
|
|
164
|
+
uv run pytest
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Releasing foliot
|
|
2
|
+
|
|
3
|
+
Publishing is intentionally automated through GitHub Actions and PyPI Trusted
|
|
4
|
+
Publishing. No long-lived PyPI token belongs in the repository.
|
|
5
|
+
|
|
6
|
+
## One-time PyPI setup
|
|
7
|
+
|
|
8
|
+
Before the first release, create a pending trusted publisher for the future
|
|
9
|
+
`foliot` project with these values:
|
|
10
|
+
|
|
11
|
+
- PyPI project name: `foliot`
|
|
12
|
+
- GitHub owner: `wordedword75049`
|
|
13
|
+
- GitHub repository: `foliot`
|
|
14
|
+
- Workflow filename: `release.yml`
|
|
15
|
+
- Environment name: `pypi`
|
|
16
|
+
|
|
17
|
+
Also create the `pypi` environment in the GitHub repository settings. See
|
|
18
|
+
[PyPI's Trusted Publishers guide](https://docs.pypi.org/trusted-publishers/).
|
|
19
|
+
|
|
20
|
+
## Prepare a release
|
|
21
|
+
|
|
22
|
+
1. Choose the version and update `project.version` in `pyproject.toml`.
|
|
23
|
+
2. Move the release notes out of `Unreleased` in `CHANGELOG.md` and add the
|
|
24
|
+
release date.
|
|
25
|
+
3. Run the complete checks:
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
uv sync --locked
|
|
29
|
+
uv run ruff format --check src tests examples
|
|
30
|
+
uv run ruff check src tests examples
|
|
31
|
+
uv run basedpyright
|
|
32
|
+
uv run pytest
|
|
33
|
+
uv build --no-sources
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
4. Inspect both files under `dist/` and install the wheel in a fresh virtual
|
|
37
|
+
environment as a smoke test.
|
|
38
|
+
5. Commit the release preparation and push it to `main`.
|
|
39
|
+
|
|
40
|
+
## Publish
|
|
41
|
+
|
|
42
|
+
Create and publish a GitHub release whose tag exactly matches the package
|
|
43
|
+
version with a `v` prefix, such as `v0.1.0`. Publishing the GitHub release
|
|
44
|
+
triggers `.github/workflows/release.yml`, which repeats all checks, builds the
|
|
45
|
+
artifacts, and sends them to PyPI through the trusted publisher.
|
|
46
|
+
|
|
47
|
+
PyPI versions are immutable. If publishing fails after a file was accepted,
|
|
48
|
+
fix the problem under a new version rather than trying to replace the file.
|
|
49
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Actions and effects
|
|
2
|
+
|
|
3
|
+
## Action ownership
|
|
4
|
+
|
|
5
|
+
Every `BaseAction` has an `entity_id`: the entity that owns the work. Foliot
|
|
6
|
+
uses it for random-stream isolation, suspension, and owner-wide deletion. It
|
|
7
|
+
does not mean “target.” Directed simulations can add a mandatory `target_id`
|
|
8
|
+
in their own shared action base.
|
|
9
|
+
|
|
10
|
+
## Admission and identity
|
|
11
|
+
|
|
12
|
+
A newly constructed action has `Unbound()` as its complete binding. The first
|
|
13
|
+
successful `Txn.schedule(...)` changes it to `Bound(seq, Active(due_tick))`.
|
|
14
|
+
|
|
15
|
+
`seq` is permanent. Rescheduling, suspending, and resuming replace the state
|
|
16
|
+
without changing the sequence number. A durable adapter must persist and
|
|
17
|
+
restore both the binding and every game-defined subclass field.
|
|
18
|
+
|
|
19
|
+
## Scheduled and recurring actions
|
|
20
|
+
|
|
21
|
+
- `due_tick=50` means the action becomes due at logical tick 50.
|
|
22
|
+
- `due_tick=None` means the action is due on every tick.
|
|
23
|
+
- A deadline must be strictly later than the tick that schedules it.
|
|
24
|
+
|
|
25
|
+
A scheduled action disappears after it runs unless it reschedules itself. A
|
|
26
|
+
recurring action remains until it calls `ctx.finish()`.
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
@override
|
|
30
|
+
def process(self, ctx: TickContext[World], /) -> None:
|
|
31
|
+
ctx.emit(Damage(self.entity_id, 2))
|
|
32
|
+
next_strike = ctx.tick + self.interval
|
|
33
|
+
if next_strike < self.expires_at:
|
|
34
|
+
ctx.schedule(self, next_strike)
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The same poison object—and therefore the same `seq` and game payload—moves to
|
|
38
|
+
the new deadline.
|
|
39
|
+
|
|
40
|
+
## Collect first, apply later
|
|
41
|
+
|
|
42
|
+
Each action receives its own `TickContext`. The context collects effects,
|
|
43
|
+
schedules, suspension requests, completion, and log lines. If the handler
|
|
44
|
+
raises, that context is discarded and the error is written to ordinary Python
|
|
45
|
+
operational logging. Other valid actions may still commit.
|
|
46
|
+
|
|
47
|
+
After all due actions have decided, valid contexts apply in stable sequence
|
|
48
|
+
order. An effect that raises is different: effects execute inside the tick
|
|
49
|
+
transaction, so the exception aborts the entire tick.
|
|
50
|
+
|
|
51
|
+
## Suspension
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
ctx.suspend(lira_id, by=fight_id)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
This pauses every suspendable action owned by Lira. Non-suspendable work such
|
|
58
|
+
as hunger or poison continues.
|
|
59
|
+
|
|
60
|
+
A suspended action remembers when it paused, who owes the wake-up, and its
|
|
61
|
+
old deadline. Resuming by the same handle shifts the stored deadline forward
|
|
62
|
+
by the exact pause duration. Override `on_resume(paused_for)` when the action
|
|
63
|
+
also has a game-owned deadline:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
@override
|
|
67
|
+
def on_resume(self, paused_for: int, /) -> None:
|
|
68
|
+
self.arrives_at += paused_for
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Effects
|
|
72
|
+
|
|
73
|
+
An effect needs only this structural shape:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
class Effect[W](Protocol):
|
|
77
|
+
def apply(self, world: W, /) -> None: ...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Foliot does not know whether the effect represents damage, healing, ownership,
|
|
81
|
+
weather, construction, or economics. Effects should normally carry all data
|
|
82
|
+
needed to apply their mutation.
|
|
83
|
+
|
|
84
|
+
## Post-effect finalization
|
|
85
|
+
|
|
86
|
+
An optional `TickFinalizer` sees the world after ordinary and Event effects.
|
|
87
|
+
It is the place for game rules such as death:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
class DeathFinalizer:
|
|
91
|
+
def finalize(self, world: World, ctx: FinalizationContext[World], /) -> None:
|
|
92
|
+
for character in world.characters.values():
|
|
93
|
+
if character.hp == 0:
|
|
94
|
+
ctx.delete_owned_by(character.entity_id)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Foliot provides the timing and cleanup tools; it never defines what death
|
|
98
|
+
means.
|
foliot-0.1.0/docs/api.md
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Public API
|
|
2
|
+
|
|
3
|
+
This page is a map of foliot's supported import surface. The docstrings on
|
|
4
|
+
each object contain the detailed parameter and error contracts.
|
|
5
|
+
|
|
6
|
+
## Core
|
|
7
|
+
|
|
8
|
+
Import these names directly from `foliot`:
|
|
9
|
+
|
|
10
|
+
- **Actions:** `BaseAction`, `ActionBinding`, `Unbound`, `Bound`, `ActionState`,
|
|
11
|
+
`Active`, and `Suspended`.
|
|
12
|
+
- **Processing:** `TickContext`, `Effect`, `Simulation`, `TickFinalizer`, and
|
|
13
|
+
`FinalizationContext`.
|
|
14
|
+
- **Time:** `Driver`, `ManualDriver`, and `RealtimeDriver`.
|
|
15
|
+
- **Storage:** `Store`, `Txn`, and `MemoryStore`.
|
|
16
|
+
- **Randomness:** `Rng`, `counter_rng`, and `new_world_seed`.
|
|
17
|
+
- **Identifiers:** `Tick`, `EntityId`, and `SuspensionId`.
|
|
18
|
+
|
|
19
|
+
`BaseAction` is the one mandatory base class. The remaining action-state
|
|
20
|
+
classes are useful when a store adapter serializes or inspects queue state.
|
|
21
|
+
|
|
22
|
+
## Optional Events
|
|
23
|
+
|
|
24
|
+
Import these names from `foliot.events` only when simultaneous decisions are
|
|
25
|
+
needed:
|
|
26
|
+
|
|
27
|
+
- **Actions and intents:** `EventAction` and `IntentRecord`.
|
|
28
|
+
- **Events and outcomes:** `BaseEvent`, `Outcome`, `DecisionContext`, and
|
|
29
|
+
`ResolutionContext`.
|
|
30
|
+
- **Integration:** `Events`, `EventStore`, `EventTxn`, and `EventMemoryStore`.
|
|
31
|
+
- **Commands:** `open_event` and `end_event`.
|
|
32
|
+
- **Stable identifiers:** `EventId`, `EventIdTemplate`, and `EntityIdTemplate`.
|
|
33
|
+
- **Configuration errors:** `EventConfigurationError`.
|
|
34
|
+
|
|
35
|
+
See [Simultaneous Events](events.md) before implementing this layer. Ordinary
|
|
36
|
+
actions, effects, stores, and drivers do not depend on it.
|
|
37
|
+
|
|
38
|
+
## Import stability
|
|
39
|
+
|
|
40
|
+
Names listed in `foliot.__all__` and `foliot.events.__all__` are the intended
|
|
41
|
+
public API. Modules and names beginning with an underscore are internal and may
|
|
42
|
+
change without notice while the project is pre-alpha.
|