dnd5e-engine 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.
Files changed (112) hide show
  1. dnd5e_engine-0.1.0/.gitignore +30 -0
  2. dnd5e_engine-0.1.0/CHANGELOG.md +23 -0
  3. dnd5e_engine-0.1.0/LICENSE +21 -0
  4. dnd5e_engine-0.1.0/Makefile +10 -0
  5. dnd5e_engine-0.1.0/NOTICE +20 -0
  6. dnd5e_engine-0.1.0/PKG-INFO +217 -0
  7. dnd5e_engine-0.1.0/README.md +173 -0
  8. dnd5e_engine-0.1.0/pyproject.toml +76 -0
  9. dnd5e_engine-0.1.0/scripts/_smoke_grid_combat.py +86 -0
  10. dnd5e_engine-0.1.0/scripts/smoke_clean_install.sh +32 -0
  11. dnd5e_engine-0.1.0/src/dnd5e_engine/__init__.py +94 -0
  12. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/__init__.py +13 -0
  13. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/apply.py +102 -0
  14. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/attack.py +439 -0
  15. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/build_context.py +219 -0
  16. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/cast.py +99 -0
  17. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/check.py +240 -0
  18. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/context.py +165 -0
  19. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/damage.py +156 -0
  20. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/dice.py +307 -0
  21. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/effects.py +238 -0
  22. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/formula.py +175 -0
  23. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/heal.py +72 -0
  24. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/mastery.py +209 -0
  25. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/monster_actions.py +183 -0
  26. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/passive_stats.py +143 -0
  27. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/resolver.py +90 -0
  28. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/save.py +286 -0
  29. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/save_primitive.py +197 -0
  30. dnd5e_engine-0.1.0/src/dnd5e_engine/activities/scale.py +164 -0
  31. dnd5e_engine-0.1.0/src/dnd5e_engine/build_party.py +132 -0
  32. dnd5e_engine-0.1.0/src/dnd5e_engine/build_spec.py +101 -0
  33. dnd5e_engine-0.1.0/src/dnd5e_engine/check.py +264 -0
  34. dnd5e_engine-0.1.0/src/dnd5e_engine/death_saves.py +155 -0
  35. dnd5e_engine-0.1.0/src/dnd5e_engine/dispatch.py +354 -0
  36. dnd5e_engine-0.1.0/src/dnd5e_engine/event_dicts.py +37 -0
  37. dnd5e_engine-0.1.0/src/dnd5e_engine/events.py +486 -0
  38. dnd5e_engine-0.1.0/src/dnd5e_engine/lib_loader.py +31 -0
  39. dnd5e_engine-0.1.0/src/dnd5e_engine/orchestrator.py +3575 -0
  40. dnd5e_engine-0.1.0/src/dnd5e_engine/outcome.py +96 -0
  41. dnd5e_engine-0.1.0/src/dnd5e_engine/py.typed +0 -0
  42. dnd5e_engine-0.1.0/src/dnd5e_engine/results.py +54 -0
  43. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/__init__.py +1 -0
  44. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/_class_meta.py +14 -0
  45. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/_parsing.py +36 -0
  46. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat.py +581 -0
  47. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat_data.py +230 -0
  48. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat_helpers.py +414 -0
  49. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/conditions.py +414 -0
  50. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/dice.py +167 -0
  51. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/effects.py +130 -0
  52. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/equipment.py +108 -0
  53. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/gambits.py +253 -0
  54. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/resolution.py +167 -0
  55. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/skills.py +240 -0
  56. dnd5e_engine-0.1.0/src/dnd5e_engine/rules/spells.py +151 -0
  57. dnd5e_engine-0.1.0/src/dnd5e_engine/spatial.py +148 -0
  58. dnd5e_engine-0.1.0/src/dnd5e_engine/specs.py +210 -0
  59. dnd5e_engine-0.1.0/src/dnd5e_engine/testing.py +43 -0
  60. dnd5e_engine-0.1.0/src/dnd5e_engine/types/__init__.py +24 -0
  61. dnd5e_engine-0.1.0/src/dnd5e_engine/types/combat.py +179 -0
  62. dnd5e_engine-0.1.0/src/dnd5e_engine/types/conditions.py +35 -0
  63. dnd5e_engine-0.1.0/src/dnd5e_engine/types/dice.py +32 -0
  64. dnd5e_engine-0.1.0/src/dnd5e_engine/types/effects.py +101 -0
  65. dnd5e_engine-0.1.0/src/dnd5e_engine/types/intent.py +170 -0
  66. dnd5e_engine-0.1.0/tests/__init__.py +0 -0
  67. dnd5e_engine-0.1.0/tests/activities/__init__.py +0 -0
  68. dnd5e_engine-0.1.0/tests/activities/test_build_context.py +178 -0
  69. dnd5e_engine-0.1.0/tests/activities/test_monster_actions.py +121 -0
  70. dnd5e_engine-0.1.0/tests/test_active_effect_schema.py +82 -0
  71. dnd5e_engine-0.1.0/tests/test_apply_changes_to_check.py +98 -0
  72. dnd5e_engine-0.1.0/tests/test_apply_modifiers.py +43 -0
  73. dnd5e_engine-0.1.0/tests/test_build_context_abilities.py +73 -0
  74. dnd5e_engine-0.1.0/tests/test_build_party.py +289 -0
  75. dnd5e_engine-0.1.0/tests/test_build_party_passives.py +111 -0
  76. dnd5e_engine-0.1.0/tests/test_combat_bucket_keys.py +178 -0
  77. dnd5e_engine-0.1.0/tests/test_combat_flag_advantage.py +154 -0
  78. dnd5e_engine-0.1.0/tests/test_concentration_drop_effect_name.py +96 -0
  79. dnd5e_engine-0.1.0/tests/test_concentration_repeat_save_identity.py +160 -0
  80. dnd5e_engine-0.1.0/tests/test_coverage_backfill_death_saves.py +122 -0
  81. dnd5e_engine-0.1.0/tests/test_coverage_backfill_seam_errors.py +53 -0
  82. dnd5e_engine-0.1.0/tests/test_duration_tick_at_turn_end.py +258 -0
  83. dnd5e_engine-0.1.0/tests/test_effects_builder_internals.py +20 -0
  84. dnd5e_engine-0.1.0/tests/test_enchantment_sidecar_projection.py +304 -0
  85. dnd5e_engine-0.1.0/tests/test_end_combat_snapshot.py +123 -0
  86. dnd5e_engine-0.1.0/tests/test_expire_clears_active_conditions.py +168 -0
  87. dnd5e_engine-0.1.0/tests/test_feature_save_dc_no_spell_override.py +66 -0
  88. dnd5e_engine-0.1.0/tests/test_get_actor_active_effects.py +149 -0
  89. dnd5e_engine-0.1.0/tests/test_grid_topology.py +128 -0
  90. dnd5e_engine-0.1.0/tests/test_iter11_fixes.py +176 -0
  91. dnd5e_engine-0.1.0/tests/test_iter13_fixes.py +207 -0
  92. dnd5e_engine-0.1.0/tests/test_iter5_fixes.py +100 -0
  93. dnd5e_engine-0.1.0/tests/test_lib_loader.py +18 -0
  94. dnd5e_engine-0.1.0/tests/test_orchestrator_gating_typed.py +482 -0
  95. dnd5e_engine-0.1.0/tests/test_orchestrator_grid_combat.py +425 -0
  96. dnd5e_engine-0.1.0/tests/test_orchestrator_monster_typed.py +518 -0
  97. dnd5e_engine-0.1.0/tests/test_orchestrator_move_mark_typed.py +151 -0
  98. dnd5e_engine-0.1.0/tests/test_orchestrator_pc_resolution_typed.py +573 -0
  99. dnd5e_engine-0.1.0/tests/test_passive_stats.py +143 -0
  100. dnd5e_engine-0.1.0/tests/test_phase6_public_surface.py +20 -0
  101. dnd5e_engine-0.1.0/tests/test_public_api_surface.py +47 -0
  102. dnd5e_engine-0.1.0/tests/test_rage_second_wind_e2e.py +219 -0
  103. dnd5e_engine-0.1.0/tests/test_resolve_check.py +304 -0
  104. dnd5e_engine-0.1.0/tests/test_resolve_check_flag_advantage.py +202 -0
  105. dnd5e_engine-0.1.0/tests/test_resolve_check_specific_buckets.py +177 -0
  106. dnd5e_engine-0.1.0/tests/test_rules_smoke.py +81 -0
  107. dnd5e_engine-0.1.0/tests/test_scale_resolver.py +207 -0
  108. dnd5e_engine-0.1.0/tests/test_smoke.py +8 -0
  109. dnd5e_engine-0.1.0/tests/test_start_combat_active_effects.py +155 -0
  110. dnd5e_engine-0.1.0/tests/test_start_combat_seeds_lifecycle.py +200 -0
  111. dnd5e_engine-0.1.0/tests/test_types_smoke.py +160 -0
  112. dnd5e_engine-0.1.0/tests/test_use_feature_intent.py +243 -0
@@ -0,0 +1,30 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.egg-info/
5
+ .venv/
6
+ dist/
7
+ build/
8
+
9
+ # uv
10
+ # (uv.lock at root IS tracked; member uv.lock files are removed)
11
+
12
+ # Tooling caches
13
+ .pytest_cache/
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .coverage
17
+ htmlcov/
18
+
19
+ # Docs build output
20
+ site/
21
+
22
+ # Editors / OS
23
+ .DS_Store
24
+ .idea/
25
+ .vscode/
26
+
27
+ # Upstream raw sources (regenerated locally via `make refresh-upstream`; never committed)
28
+ packages/dnd5e-srd-data/raw_sources/foundry/
29
+ packages/dnd5e-srd-data/raw_sources/open5e/
30
+ packages/dnd5e-srd-data/raw_sources/five_e_bits/
@@ -0,0 +1,23 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ First public release.
13
+
14
+ ### Added
15
+
16
+ - Pure-Python, host-agnostic D&D 5e SRD rules engine: combat orchestration,
17
+ ability checks and saving throws, and combat-scoped effects.
18
+ - SRD 5.2 (2024) content resolution against the typed-Activity corpus via
19
+ `BundledAssetLoader`, reading the canonical dataset from the
20
+ `dnd5e-srd-data` package.
21
+ - Curated public API surface (`__all__`) with a surface guard test.
22
+ - `py.typed` marker — the package ships inline type information.
23
+ - Zero-I/O guarantee: no DB, network, or async dependencies in the engine.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tapestria contributors
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
13
+ all 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
21
+ THE SOFTWARE.
@@ -0,0 +1,10 @@
1
+ .PHONY: check lint type test smoke
2
+ check: lint type test
3
+ lint:
4
+ uv run ruff check .
5
+ type:
6
+ uv run mypy src/
7
+ test:
8
+ uv run pytest -q
9
+ smoke:
10
+ bash scripts/smoke_clean_install.sh
@@ -0,0 +1,20 @@
1
+ dnd5e-engine — pure-Python D&D 5e SRD rules engine.
2
+
3
+ The engine CODE is licensed under the MIT License (see LICENSE). It ships NO
4
+ SRD data: the canonical rules content lives in the separate dnd5e-srd-data
5
+ package, which the engine consumes at runtime via BundledAssetLoader.
6
+
7
+ The rules this engine implements derive from the D&D 5e System Reference
8
+ Document. The provenance of that ruleset (carried by the dnd5e-srd-data
9
+ dataset, distributed under CC-BY-4.0) is:
10
+
11
+ Attribution:
12
+ - Portions derived from the Foundry VTT dnd5e system
13
+ (https://github.com/foundryvtt/dnd5e), CC-BY-4.0.
14
+ - Cross-checked against open5e (https://open5e.com), CC-BY-4.0, and the
15
+ 5e-bits/5e-database project (https://github.com/5e-bits/5e-database), MIT.
16
+ - Original source: System Reference Document 5.1 and 5.2 by Wizards of the
17
+ Coast LLC, CC-BY-4.0.
18
+
19
+ See the dnd5e-srd-data package for the bundled dataset and its upstream
20
+ license texts.
@@ -0,0 +1,217 @@
1
+ Metadata-Version: 2.4
2
+ Name: dnd5e-engine
3
+ Version: 0.1.0
4
+ Summary: Pure-Python D&D 5e SRD rules engine — combat, checks, effects. Host-agnostic, zero I/O.
5
+ Author: Tapestria contributors
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 Tapestria contributors
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in
18
+ all copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
26
+ THE SOFTWARE.
27
+ License-File: LICENSE
28
+ License-File: NOTICE
29
+ Keywords: 5e,dnd,rules-engine,srd,ttrpg
30
+ Classifier: License :: OSI Approved :: MIT License
31
+ Classifier: Programming Language :: Python :: 3
32
+ Classifier: Programming Language :: Python :: 3.12
33
+ Classifier: Programming Language :: Python :: 3.13
34
+ Classifier: Typing :: Typed
35
+ Requires-Python: >=3.12
36
+ Requires-Dist: d20>=1.1.2
37
+ Requires-Dist: dnd5e-srd-data>=0.1.0
38
+ Requires-Dist: pydantic>=2.10.0
39
+ Provides-Extra: dev
40
+ Requires-Dist: mypy>=1.13.0; extra == 'dev'
41
+ Requires-Dist: pytest>=8.3.0; extra == 'dev'
42
+ Requires-Dist: ruff>=0.9.0; extra == 'dev'
43
+ Description-Content-Type: text/markdown
44
+
45
+ # dnd5e-engine
46
+
47
+ Pure-Python D&D 5e SRD rules engine — host-agnostic, zero I/O. Combat, checks, effects,
48
+ on a zone graph or a 2-D grid.
49
+
50
+ ## Status
51
+
52
+ Working engine. It resolves combat against the typed 2024-SRD corpus shipped by
53
+ [`dnd5e-srd-data`](../dnd5e-srd-data) (the `BundledAssetLoader` reads the bundled
54
+ `canonical/` data — no network, no DB). The engine is edition-agnostic: it consumes
55
+ whatever typed content it is handed.
56
+
57
+ Two spatial backends are supported, selected at `start_combat`:
58
+
59
+ - **Zone graph** — pass `scene_zones=SceneTopology(zones=..., edges=...)`; range/reach are
60
+ resolved as shortest-path distance over a weighted, undirected zone graph.
61
+ - **2-D grid** — pass `grid_scene=GridScene(width, height)`; positions are `"col,row"` cell
62
+ ids (`cell_id(col, row)`), distance is Chebyshev (8-direction, one cell = `cell_size_ft`).
63
+
64
+ ## Install
65
+
66
+ Dev (editable, from this directory):
67
+
68
+ ```bash
69
+ cd packages/dnd5e-engine
70
+ uv venv && uv pip install -e '.[dev]'
71
+ uv run --extra dev pytest -q
72
+ uv run --extra dev ruff check src/ tests/ scripts/
73
+ uv run --extra dev mypy src/
74
+ ```
75
+
76
+ Standalone wheel:
77
+
78
+ ```bash
79
+ uv build # builds dist/dnd5e_engine-*.whl (+ sdist)
80
+ ```
81
+
82
+ A clean-room install smoke builds both wheels, installs them into a throwaway venv with no
83
+ editable path deps, and runs a real grid combat through the public API:
84
+
85
+ ```bash
86
+ bash scripts/smoke_clean_install.sh # ends with "==> SMOKE PASSED"
87
+ ```
88
+
89
+ ## Quickstart
90
+
91
+ A minimal grid combat: open it, move a PC one cell, then close it.
92
+
93
+ ```python
94
+ import asyncio
95
+
96
+ from dnd5e_engine import (
97
+ EncounterMemberSpec,
98
+ GridScene,
99
+ PartyMemberSpec,
100
+ PlayerIntent,
101
+ cell_id,
102
+ end_combat,
103
+ start_combat,
104
+ submit_player_intent,
105
+ )
106
+
107
+
108
+ async def main() -> None:
109
+ start = await start_combat(
110
+ session_id="demo",
111
+ party=[
112
+ PartyMemberSpec(
113
+ entity_id="char:hero",
114
+ name="Hero",
115
+ initiative=20,
116
+ hp_current=12,
117
+ hp_max=12,
118
+ ac=12,
119
+ zone_id=cell_id(0, 0),
120
+ )
121
+ ],
122
+ encounter=[
123
+ EncounterMemberSpec(
124
+ entity_id="mon:foe",
125
+ entity_type="Monster",
126
+ name="Foe",
127
+ initiative=1,
128
+ hp_current=7,
129
+ hp_max=7,
130
+ zone_id=cell_id(5, 0),
131
+ )
132
+ ],
133
+ grid_scene=GridScene(width=10, height=10),
134
+ rng_seed=1,
135
+ )
136
+
137
+ # The hero won initiative; move one cell diagonally.
138
+ await submit_player_intent(
139
+ start.handle,
140
+ actor_id="char:hero",
141
+ intent=PlayerIntent(intent_type="move", target_zone_id=cell_id(1, 1)),
142
+ )
143
+
144
+ result = await end_combat(start.handle)
145
+ print("ended reason:", result.outcome.ended_reason)
146
+
147
+
148
+ asyncio.run(main())
149
+ ```
150
+
151
+ An attack instead of a move: submit
152
+ `PlayerIntent(intent_type="attack", target_id="mon:foe", weapon_id="longsword")` — the
153
+ engine fetches the typed weapon from the bundled corpus and walks its activities.
154
+
155
+ ## Public API
156
+
157
+ All exported names live in `__all__` in `src/dnd5e_engine/__init__.py`. The key entry points:
158
+
159
+ - `start_combat(*, session_id, party, encounter, scene_zones=|grid_scene=, rng_seed, ...)` —
160
+ open a combat, materialize runtime state, return a `StartCombatResult` (`.handle`, opening
161
+ `.events`).
162
+ - `submit_player_intent(handle, actor_id, intent)` — validate and resolve a PC's
163
+ `PlayerIntent` for the current turn.
164
+ - `advance_monster_turn(handle)` — resolve the active monster's turn via its typed action
165
+ repertoire + behavior gambits.
166
+ - `end_combat(handle)` — close a combat and return an `EndCombatResult` (`.outcome`,
167
+ `.events`, `.final_active_effects`).
168
+ - `narration_events(handle)` — async iterator streaming the `CombatEvent` union for the
169
+ narrator.
170
+ - `get_actor_active_effects(handle, entity_id)` — read-only snapshot of one combatant's
171
+ active effects.
172
+ - `resolve_check(spec)` — resolve an out-of-combat ability check / saving throw
173
+ (`CheckSpec` → `CheckResult`).
174
+ - `build_party_member(spec, ...)` — project a `CharacterBuildSpec` into a combat-ready
175
+ `PartyMemberSpec`.
176
+ - `make_build_spec(...)` — assemble a `CharacterBuildSpec` (ability scores, class, species).
177
+
178
+ Spatial helpers and spec types:
179
+
180
+ - `GridScene` / `cell_id(col, row)` / `parse_cell(cell_id)` — 2-D grid scene + cell-id codec.
181
+ - `SceneTopology` / `ZoneEdge` — zone-graph scene description.
182
+ - `PartyMemberSpec` / `EncounterMemberSpec` — combatant inputs to `start_combat`.
183
+ - `PlayerIntent` — a PC's submitted intent (move / attack / cast_spell / use_item / ...).
184
+ - `CombatHandle`, `StartCombatResult`, `EndCombatResult`, `CombatOutcome`, `CombatEvent`,
185
+ `ActiveEffect`, `CheckSpec`/`CheckResult`/`CheckKind`, `CharacterBuildSpec`,
186
+ `AbilityScores`, and the `IntentType` / `ActionType` literals.
187
+
188
+ ## Layout
189
+
190
+ ```
191
+ packages/dnd5e-engine/
192
+ ├── pyproject.toml hatchling build; pydantic + d20 + dnd5e-srd-data deps
193
+ ├── LICENSE MIT (engine code)
194
+ ├── scripts/
195
+ │ ├── _smoke_grid_combat.py clean-room smoke program (runs in a fresh venv)
196
+ │ └── smoke_clean_install.sh builds wheels + installs them + runs the smoke
197
+ ├── src/dnd5e_engine/
198
+ │ ├── __init__.py public API (__all__)
199
+ │ ├── orchestrator.py start/submit/advance/end combat seam + live state
200
+ │ ├── spatial.py grid + zone topologies, cell_id / parse_cell
201
+ │ ├── specs.py GridScene, SceneTopology, party/encounter specs
202
+ │ ├── check.py out-of-combat ability check / saving throw resolver
203
+ │ ├── build_party.py / build_spec.py character build → combat spec projection
204
+ │ ├── lib_loader.py BundledAssetLoader singleton (typed SRD corpus)
205
+ │ ├── events.py / outcome.py / results.py event union, outcome, result envelopes
206
+ │ ├── activities/ typed-Activity resolvers (attack / save / monster actions)
207
+ │ ├── rules/ dice, conditions, gambits, ...
208
+ │ └── types/ combat / effects / conditions / intent value types
209
+ └── tests/ pytest suite
210
+ ```
211
+
212
+ ## License
213
+
214
+ - Engine code: MIT — see [`LICENSE`](LICENSE). The engine ships no SRD data.
215
+ - SRD content (the typed corpus consumed via `dnd5e-srd-data`): CC-BY-4.0 — see the
216
+ [`dnd5e-srd-data`](../dnd5e-srd-data) package's `LICENSE` and `NOTICE` for the
217
+ dataset license and attribution chain.
@@ -0,0 +1,173 @@
1
+ # dnd5e-engine
2
+
3
+ Pure-Python D&D 5e SRD rules engine — host-agnostic, zero I/O. Combat, checks, effects,
4
+ on a zone graph or a 2-D grid.
5
+
6
+ ## Status
7
+
8
+ Working engine. It resolves combat against the typed 2024-SRD corpus shipped by
9
+ [`dnd5e-srd-data`](../dnd5e-srd-data) (the `BundledAssetLoader` reads the bundled
10
+ `canonical/` data — no network, no DB). The engine is edition-agnostic: it consumes
11
+ whatever typed content it is handed.
12
+
13
+ Two spatial backends are supported, selected at `start_combat`:
14
+
15
+ - **Zone graph** — pass `scene_zones=SceneTopology(zones=..., edges=...)`; range/reach are
16
+ resolved as shortest-path distance over a weighted, undirected zone graph.
17
+ - **2-D grid** — pass `grid_scene=GridScene(width, height)`; positions are `"col,row"` cell
18
+ ids (`cell_id(col, row)`), distance is Chebyshev (8-direction, one cell = `cell_size_ft`).
19
+
20
+ ## Install
21
+
22
+ Dev (editable, from this directory):
23
+
24
+ ```bash
25
+ cd packages/dnd5e-engine
26
+ uv venv && uv pip install -e '.[dev]'
27
+ uv run --extra dev pytest -q
28
+ uv run --extra dev ruff check src/ tests/ scripts/
29
+ uv run --extra dev mypy src/
30
+ ```
31
+
32
+ Standalone wheel:
33
+
34
+ ```bash
35
+ uv build # builds dist/dnd5e_engine-*.whl (+ sdist)
36
+ ```
37
+
38
+ A clean-room install smoke builds both wheels, installs them into a throwaway venv with no
39
+ editable path deps, and runs a real grid combat through the public API:
40
+
41
+ ```bash
42
+ bash scripts/smoke_clean_install.sh # ends with "==> SMOKE PASSED"
43
+ ```
44
+
45
+ ## Quickstart
46
+
47
+ A minimal grid combat: open it, move a PC one cell, then close it.
48
+
49
+ ```python
50
+ import asyncio
51
+
52
+ from dnd5e_engine import (
53
+ EncounterMemberSpec,
54
+ GridScene,
55
+ PartyMemberSpec,
56
+ PlayerIntent,
57
+ cell_id,
58
+ end_combat,
59
+ start_combat,
60
+ submit_player_intent,
61
+ )
62
+
63
+
64
+ async def main() -> None:
65
+ start = await start_combat(
66
+ session_id="demo",
67
+ party=[
68
+ PartyMemberSpec(
69
+ entity_id="char:hero",
70
+ name="Hero",
71
+ initiative=20,
72
+ hp_current=12,
73
+ hp_max=12,
74
+ ac=12,
75
+ zone_id=cell_id(0, 0),
76
+ )
77
+ ],
78
+ encounter=[
79
+ EncounterMemberSpec(
80
+ entity_id="mon:foe",
81
+ entity_type="Monster",
82
+ name="Foe",
83
+ initiative=1,
84
+ hp_current=7,
85
+ hp_max=7,
86
+ zone_id=cell_id(5, 0),
87
+ )
88
+ ],
89
+ grid_scene=GridScene(width=10, height=10),
90
+ rng_seed=1,
91
+ )
92
+
93
+ # The hero won initiative; move one cell diagonally.
94
+ await submit_player_intent(
95
+ start.handle,
96
+ actor_id="char:hero",
97
+ intent=PlayerIntent(intent_type="move", target_zone_id=cell_id(1, 1)),
98
+ )
99
+
100
+ result = await end_combat(start.handle)
101
+ print("ended reason:", result.outcome.ended_reason)
102
+
103
+
104
+ asyncio.run(main())
105
+ ```
106
+
107
+ An attack instead of a move: submit
108
+ `PlayerIntent(intent_type="attack", target_id="mon:foe", weapon_id="longsword")` — the
109
+ engine fetches the typed weapon from the bundled corpus and walks its activities.
110
+
111
+ ## Public API
112
+
113
+ All exported names live in `__all__` in `src/dnd5e_engine/__init__.py`. The key entry points:
114
+
115
+ - `start_combat(*, session_id, party, encounter, scene_zones=|grid_scene=, rng_seed, ...)` —
116
+ open a combat, materialize runtime state, return a `StartCombatResult` (`.handle`, opening
117
+ `.events`).
118
+ - `submit_player_intent(handle, actor_id, intent)` — validate and resolve a PC's
119
+ `PlayerIntent` for the current turn.
120
+ - `advance_monster_turn(handle)` — resolve the active monster's turn via its typed action
121
+ repertoire + behavior gambits.
122
+ - `end_combat(handle)` — close a combat and return an `EndCombatResult` (`.outcome`,
123
+ `.events`, `.final_active_effects`).
124
+ - `narration_events(handle)` — async iterator streaming the `CombatEvent` union for the
125
+ narrator.
126
+ - `get_actor_active_effects(handle, entity_id)` — read-only snapshot of one combatant's
127
+ active effects.
128
+ - `resolve_check(spec)` — resolve an out-of-combat ability check / saving throw
129
+ (`CheckSpec` → `CheckResult`).
130
+ - `build_party_member(spec, ...)` — project a `CharacterBuildSpec` into a combat-ready
131
+ `PartyMemberSpec`.
132
+ - `make_build_spec(...)` — assemble a `CharacterBuildSpec` (ability scores, class, species).
133
+
134
+ Spatial helpers and spec types:
135
+
136
+ - `GridScene` / `cell_id(col, row)` / `parse_cell(cell_id)` — 2-D grid scene + cell-id codec.
137
+ - `SceneTopology` / `ZoneEdge` — zone-graph scene description.
138
+ - `PartyMemberSpec` / `EncounterMemberSpec` — combatant inputs to `start_combat`.
139
+ - `PlayerIntent` — a PC's submitted intent (move / attack / cast_spell / use_item / ...).
140
+ - `CombatHandle`, `StartCombatResult`, `EndCombatResult`, `CombatOutcome`, `CombatEvent`,
141
+ `ActiveEffect`, `CheckSpec`/`CheckResult`/`CheckKind`, `CharacterBuildSpec`,
142
+ `AbilityScores`, and the `IntentType` / `ActionType` literals.
143
+
144
+ ## Layout
145
+
146
+ ```
147
+ packages/dnd5e-engine/
148
+ ├── pyproject.toml hatchling build; pydantic + d20 + dnd5e-srd-data deps
149
+ ├── LICENSE MIT (engine code)
150
+ ├── scripts/
151
+ │ ├── _smoke_grid_combat.py clean-room smoke program (runs in a fresh venv)
152
+ │ └── smoke_clean_install.sh builds wheels + installs them + runs the smoke
153
+ ├── src/dnd5e_engine/
154
+ │ ├── __init__.py public API (__all__)
155
+ │ ├── orchestrator.py start/submit/advance/end combat seam + live state
156
+ │ ├── spatial.py grid + zone topologies, cell_id / parse_cell
157
+ │ ├── specs.py GridScene, SceneTopology, party/encounter specs
158
+ │ ├── check.py out-of-combat ability check / saving throw resolver
159
+ │ ├── build_party.py / build_spec.py character build → combat spec projection
160
+ │ ├── lib_loader.py BundledAssetLoader singleton (typed SRD corpus)
161
+ │ ├── events.py / outcome.py / results.py event union, outcome, result envelopes
162
+ │ ├── activities/ typed-Activity resolvers (attack / save / monster actions)
163
+ │ ├── rules/ dice, conditions, gambits, ...
164
+ │ └── types/ combat / effects / conditions / intent value types
165
+ └── tests/ pytest suite
166
+ ```
167
+
168
+ ## License
169
+
170
+ - Engine code: MIT — see [`LICENSE`](LICENSE). The engine ships no SRD data.
171
+ - SRD content (the typed corpus consumed via `dnd5e-srd-data`): CC-BY-4.0 — see the
172
+ [`dnd5e-srd-data`](../dnd5e-srd-data) package's `LICENSE` and `NOTICE` for the
173
+ dataset license and attribution chain.
@@ -0,0 +1,76 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dnd5e-engine"
7
+ version = "0.1.0"
8
+ description = "Pure-Python D&D 5e SRD rules engine — combat, checks, effects. Host-agnostic, zero I/O."
9
+ readme = "README.md"
10
+ requires-python = ">=3.12"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Tapestria contributors" }]
13
+ keywords = ["dnd", "5e", "srd", "ttrpg", "rules-engine"]
14
+ classifiers = [
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Typing :: Typed",
20
+ ]
21
+ dependencies = [
22
+ "pydantic>=2.10.0",
23
+ "d20>=1.1.2",
24
+ # lib_loader imports BundledAssetLoader at runtime; the typed-Activity
25
+ # resolver reads the canonical corpus through it. The data lib's >=3.12
26
+ # floor is why this package now also requires >=3.12 (see requires-python).
27
+ "dnd5e-srd-data>=0.1.0",
28
+ ]
29
+
30
+ [project.optional-dependencies]
31
+ dev = [
32
+ "pytest>=8.3.0",
33
+ "ruff>=0.9.0",
34
+ "mypy>=1.13.0",
35
+ ]
36
+
37
+ [tool.uv.sources]
38
+ dnd5e-srd-data = { workspace = true }
39
+
40
+ [tool.hatch.build.targets.wheel]
41
+ packages = ["src/dnd5e_engine"]
42
+
43
+ [tool.mypy]
44
+ python_version = "3.12"
45
+ plugins = ["pydantic.mypy"]
46
+ strict = true
47
+ warn_return_any = true
48
+ warn_unused_configs = true
49
+ warn_unused_ignores = true
50
+
51
+ # d20 ships no py.typed marker / stubs.
52
+ [[tool.mypy.overrides]]
53
+ module = ["d20"]
54
+ ignore_missing_imports = true
55
+
56
+ [tool.ruff]
57
+ line-length = 100
58
+ target-version = "py312"
59
+
60
+ [tool.ruff.lint]
61
+ select = [
62
+ "E", "F", "I", "N", "UP", "B", "SIM", "T20", "BLE",
63
+ "ASYNC", "PT", "RUF", "TRY",
64
+ ]
65
+ ignore = [
66
+ "TRY003",
67
+ "RUF001", "RUF002", "RUF003",
68
+ ]
69
+
70
+ [tool.ruff.lint.per-file-ignores]
71
+ "tests/**/*.py" = ["E501", "F841", "PT006", "PT011"]
72
+ # Smoke scripts print human-readable progress/result lines (T201) by design.
73
+ "scripts/**/*.py" = ["T201"]
74
+
75
+ [tool.pytest.ini_options]
76
+ testpaths = ["tests"]
@@ -0,0 +1,86 @@
1
+ """Clean-room smoke: exercise the installed dnd5e-engine wheel end-to-end.
2
+
3
+ Run by scripts/smoke_clean_install.sh inside a fresh venv that has ONLY the
4
+ built wheels installed. Exits non-zero on any failure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+
11
+ from dnd5e_engine import (
12
+ EncounterMemberSpec,
13
+ GridScene,
14
+ PartyMemberSpec,
15
+ PlayerIntent,
16
+ cell_id,
17
+ end_combat,
18
+ start_combat,
19
+ submit_player_intent,
20
+ )
21
+ from dnd5e_engine.lib_loader import get_lib_loader
22
+ from dnd5e_engine.orchestrator import get_live
23
+
24
+ # A genuine bundled monster slug — canonical/monsters/ ships goblin-warrior.json
25
+ # (there is no bare "goblin.json"). The smoke asserts the corpus resolves a slug
26
+ # that really ships in the dnd5e-srd-data wheel.
27
+ _BUNDLED_MONSTER_SLUG = "goblin-warrior"
28
+
29
+
30
+ def _check_corpus() -> None:
31
+ loader = get_lib_loader() # BundledAssetLoader → reads bundled canonical/
32
+ monster = loader.get_monster(_BUNDLED_MONSTER_SLUG)
33
+ assert monster is not None, (
34
+ f"bundled corpus did not resolve monster {_BUNDLED_MONSTER_SLUG!r}"
35
+ )
36
+
37
+
38
+ async def _run_grid_combat() -> None:
39
+ start = await start_combat(
40
+ session_id="smoke",
41
+ party=[
42
+ PartyMemberSpec(
43
+ entity_id="char:hero",
44
+ name="Hero",
45
+ initiative=20,
46
+ hp_current=12,
47
+ hp_max=12,
48
+ ac=12,
49
+ zone_id=cell_id(0, 0),
50
+ )
51
+ ],
52
+ encounter=[
53
+ EncounterMemberSpec(
54
+ entity_id="mon:foe",
55
+ entity_type="Monster",
56
+ name="Foe",
57
+ initiative=1,
58
+ hp_current=7,
59
+ hp_max=7,
60
+ zone_id=cell_id(5, 0),
61
+ )
62
+ ],
63
+ grid_scene=GridScene(width=10, height=10),
64
+ rng_seed=1,
65
+ )
66
+ live = get_live(start.handle)
67
+ assert live.actor_zone["char:hero"] == "0,0"
68
+ # One legal single-cell move proves the grid MOVE path resolves end-to-end.
69
+ await submit_player_intent(
70
+ start.handle,
71
+ actor_id="char:hero",
72
+ intent=PlayerIntent(intent_type="move", target_zone_id=cell_id(1, 1)),
73
+ )
74
+ assert live.actor_zone["char:hero"] == "1,1", "grid move did not apply"
75
+ result = await end_combat(start.handle)
76
+ assert result is not None
77
+ print("SMOKE OK: corpus loaded + grid combat ran (hero moved 0,0 -> 1,1)")
78
+
79
+
80
+ def main() -> None:
81
+ _check_corpus()
82
+ asyncio.run(_run_grid_combat())
83
+
84
+
85
+ if __name__ == "__main__":
86
+ main()