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.
- dnd5e_engine-0.1.0/.gitignore +30 -0
- dnd5e_engine-0.1.0/CHANGELOG.md +23 -0
- dnd5e_engine-0.1.0/LICENSE +21 -0
- dnd5e_engine-0.1.0/Makefile +10 -0
- dnd5e_engine-0.1.0/NOTICE +20 -0
- dnd5e_engine-0.1.0/PKG-INFO +217 -0
- dnd5e_engine-0.1.0/README.md +173 -0
- dnd5e_engine-0.1.0/pyproject.toml +76 -0
- dnd5e_engine-0.1.0/scripts/_smoke_grid_combat.py +86 -0
- dnd5e_engine-0.1.0/scripts/smoke_clean_install.sh +32 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/__init__.py +94 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/__init__.py +13 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/apply.py +102 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/attack.py +439 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/build_context.py +219 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/cast.py +99 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/check.py +240 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/context.py +165 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/damage.py +156 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/dice.py +307 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/effects.py +238 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/formula.py +175 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/heal.py +72 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/mastery.py +209 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/monster_actions.py +183 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/passive_stats.py +143 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/resolver.py +90 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/save.py +286 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/save_primitive.py +197 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/activities/scale.py +164 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/build_party.py +132 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/build_spec.py +101 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/check.py +264 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/death_saves.py +155 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/dispatch.py +354 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/event_dicts.py +37 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/events.py +486 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/lib_loader.py +31 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/orchestrator.py +3575 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/outcome.py +96 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/py.typed +0 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/results.py +54 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/__init__.py +1 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/_class_meta.py +14 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/_parsing.py +36 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat.py +581 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat_data.py +230 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/combat_helpers.py +414 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/conditions.py +414 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/dice.py +167 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/effects.py +130 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/equipment.py +108 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/gambits.py +253 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/resolution.py +167 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/skills.py +240 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/rules/spells.py +151 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/spatial.py +148 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/specs.py +210 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/testing.py +43 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/__init__.py +24 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/combat.py +179 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/conditions.py +35 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/dice.py +32 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/effects.py +101 -0
- dnd5e_engine-0.1.0/src/dnd5e_engine/types/intent.py +170 -0
- dnd5e_engine-0.1.0/tests/__init__.py +0 -0
- dnd5e_engine-0.1.0/tests/activities/__init__.py +0 -0
- dnd5e_engine-0.1.0/tests/activities/test_build_context.py +178 -0
- dnd5e_engine-0.1.0/tests/activities/test_monster_actions.py +121 -0
- dnd5e_engine-0.1.0/tests/test_active_effect_schema.py +82 -0
- dnd5e_engine-0.1.0/tests/test_apply_changes_to_check.py +98 -0
- dnd5e_engine-0.1.0/tests/test_apply_modifiers.py +43 -0
- dnd5e_engine-0.1.0/tests/test_build_context_abilities.py +73 -0
- dnd5e_engine-0.1.0/tests/test_build_party.py +289 -0
- dnd5e_engine-0.1.0/tests/test_build_party_passives.py +111 -0
- dnd5e_engine-0.1.0/tests/test_combat_bucket_keys.py +178 -0
- dnd5e_engine-0.1.0/tests/test_combat_flag_advantage.py +154 -0
- dnd5e_engine-0.1.0/tests/test_concentration_drop_effect_name.py +96 -0
- dnd5e_engine-0.1.0/tests/test_concentration_repeat_save_identity.py +160 -0
- dnd5e_engine-0.1.0/tests/test_coverage_backfill_death_saves.py +122 -0
- dnd5e_engine-0.1.0/tests/test_coverage_backfill_seam_errors.py +53 -0
- dnd5e_engine-0.1.0/tests/test_duration_tick_at_turn_end.py +258 -0
- dnd5e_engine-0.1.0/tests/test_effects_builder_internals.py +20 -0
- dnd5e_engine-0.1.0/tests/test_enchantment_sidecar_projection.py +304 -0
- dnd5e_engine-0.1.0/tests/test_end_combat_snapshot.py +123 -0
- dnd5e_engine-0.1.0/tests/test_expire_clears_active_conditions.py +168 -0
- dnd5e_engine-0.1.0/tests/test_feature_save_dc_no_spell_override.py +66 -0
- dnd5e_engine-0.1.0/tests/test_get_actor_active_effects.py +149 -0
- dnd5e_engine-0.1.0/tests/test_grid_topology.py +128 -0
- dnd5e_engine-0.1.0/tests/test_iter11_fixes.py +176 -0
- dnd5e_engine-0.1.0/tests/test_iter13_fixes.py +207 -0
- dnd5e_engine-0.1.0/tests/test_iter5_fixes.py +100 -0
- dnd5e_engine-0.1.0/tests/test_lib_loader.py +18 -0
- dnd5e_engine-0.1.0/tests/test_orchestrator_gating_typed.py +482 -0
- dnd5e_engine-0.1.0/tests/test_orchestrator_grid_combat.py +425 -0
- dnd5e_engine-0.1.0/tests/test_orchestrator_monster_typed.py +518 -0
- dnd5e_engine-0.1.0/tests/test_orchestrator_move_mark_typed.py +151 -0
- dnd5e_engine-0.1.0/tests/test_orchestrator_pc_resolution_typed.py +573 -0
- dnd5e_engine-0.1.0/tests/test_passive_stats.py +143 -0
- dnd5e_engine-0.1.0/tests/test_phase6_public_surface.py +20 -0
- dnd5e_engine-0.1.0/tests/test_public_api_surface.py +47 -0
- dnd5e_engine-0.1.0/tests/test_rage_second_wind_e2e.py +219 -0
- dnd5e_engine-0.1.0/tests/test_resolve_check.py +304 -0
- dnd5e_engine-0.1.0/tests/test_resolve_check_flag_advantage.py +202 -0
- dnd5e_engine-0.1.0/tests/test_resolve_check_specific_buckets.py +177 -0
- dnd5e_engine-0.1.0/tests/test_rules_smoke.py +81 -0
- dnd5e_engine-0.1.0/tests/test_scale_resolver.py +207 -0
- dnd5e_engine-0.1.0/tests/test_smoke.py +8 -0
- dnd5e_engine-0.1.0/tests/test_start_combat_active_effects.py +155 -0
- dnd5e_engine-0.1.0/tests/test_start_combat_seeds_lifecycle.py +200 -0
- dnd5e_engine-0.1.0/tests/test_types_smoke.py +160 -0
- 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,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()
|