dndwright 0.23.0__tar.gz → 0.23.2__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.
- {dndwright-0.23.0 → dndwright-0.23.2}/CHANGELOG.md +44 -1
- {dndwright-0.23.0 → dndwright-0.23.2}/PKG-INFO +39 -1
- {dndwright-0.23.0 → dndwright-0.23.2}/README.md +38 -0
- dndwright-0.23.2/assets/compose.svg +121 -0
- dndwright-0.23.2/assets/theme-scaling.svg +107 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/pyproject.toml +1 -1
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/__init__.py +1 -1
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/cli.py +9 -4
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/dice/engine.py +8 -3
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/adapters.py +1 -1
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/assembler.py +2 -3
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/compose.py +8 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/evaluator.py +7 -5
- dndwright-0.23.2/tests/test_api_contract.py +331 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_dice.py +16 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_species_components.py +12 -0
- dndwright-0.23.0/tests/test_api_contract.py +0 -114
- {dndwright-0.23.0 → dndwright-0.23.2}/.github/workflows/ci.yml +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/.github/workflows/publish.yml +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/.gitignore +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/LICENSE +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/NOTICE +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/RELEASING.md +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/assets/combat-flow.svg +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/assets/computation-graph.svg +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/assets/dice.svg +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/assets/overview.svg +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/README.md +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/combat.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/compose.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/conditions.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/content_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/custom_operation.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/dice.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/export_graph.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/feat_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/gated_contributions.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/initiative.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/multiclass.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/quickstart.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/stat_diff.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/examples/weapon_attack.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/__init__.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/combat.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/conditions.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/initiative.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/weapons.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/__init__.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/armor.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/backgrounds.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/classes.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/conditions.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/creatures.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/feats.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/generate.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/magic_items.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/models.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/species.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/spells.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/weapons.json +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/dice/__init__.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/__init__.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/dnd.yaml +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/loader.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/py.typed +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/__init__.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/character_evaluator.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/dnd_5e_2024.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/export.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/lookup_tables.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/operations.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/schema.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/theme_scaling.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/validation.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_background_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_backgrounds.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_character_theme_scaling.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_classes.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_cli.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_combat.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_combat_api_contract.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_compose.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_conditions.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content_models.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_creatures.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_defenses_and_serialization.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_dice_api_contract.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_engine.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_evaluator_cache.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_examples.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_export.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_feat_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_gated_contributions.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_initiative.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_input_validation.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_ontology.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_operations_registry.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_properties.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_public_graph_api.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_spell_components.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_spells.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_theme_scaling.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_validation.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_weapon_attack.py +0 -0
- {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_weapons_armor.py +0 -0
|
@@ -10,6 +10,47 @@ breaking changes; these will always be noted here.
|
|
|
10
10
|
|
|
11
11
|
## [Unreleased]
|
|
12
12
|
|
|
13
|
+
## [0.23.2] — 2026-06-06
|
|
14
|
+
|
|
15
|
+
### Documentation
|
|
16
|
+
- **Two new README/PyPI diagrams.** `assets/compose.svg` shows lego-style components snapping
|
|
17
|
+
onto the ruleset — typed `set`/`add`/`union` contributions that keep the target node's id, so
|
|
18
|
+
one snap-in recomputes the whole downstream subtree. `assets/theme-scaling.svg` shows one
|
|
19
|
+
computation graph re-skinned per setting via `ThemeScalingLayer`: the same `plate` node emerges
|
|
20
|
+
as AC 18 (traditional / modern, re-flavored) or AC 19 (sci-fi / steampunk) through
|
|
21
|
+
`input_overrides`, `lookup_overrides`, and display-only `flavor_renames`.
|
|
22
|
+
- **New README sections** — "Composable — snap mini-graphs onto the ruleset" and "Re-skin for
|
|
23
|
+
any setting — theme scaling" (theme scaling was previously undocumented in the README), plus a
|
|
24
|
+
`apply_theme_scaling` / `ThemeScalingLayer` row in the feature table. Docs-only; no code change.
|
|
25
|
+
|
|
26
|
+
## [0.23.1] — 2026-06-06
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **Critical damage preserves dice modifiers** — `DiceEngine.roll_damage(expr, is_critical=True)`
|
|
30
|
+
doubled die counts by rebuilding the expression as bare `XdY`, silently dropping per-group
|
|
31
|
+
`keep`/`drop`/`reroll`/`exploding` flags. A crit on `1d6!+2` now stays exploding (`2d6!+2`)
|
|
32
|
+
instead of becoming a plain `2d6+2`. As part of the fix, `str(DiceGroup)` now serialises
|
|
33
|
+
reroll-once as `ro` (was `r`), so the spec round-trips through `parse_expression`.
|
|
34
|
+
- **Species damage immunities mis-categorised** — `_build_species_traits` tagged damage
|
|
35
|
+
immunities with `category: "resistance"` (condition immunities were already correct); they
|
|
36
|
+
are now `category: "immunity"`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- **`compose()` fails loudly on contradictory contributions** — a target that receives both a
|
|
40
|
+
`union` (set-valued) and an `add`/`set` (numeric) contribution now raises `ValueError` instead
|
|
41
|
+
of silently dropping the numeric ones.
|
|
42
|
+
|
|
43
|
+
### Performance
|
|
44
|
+
- **Topological sort** uses a binary heap (`heapq`) instead of re-sorting the ready-queue every
|
|
45
|
+
iteration — same deterministic order, `O(V·log V + E)` instead of `O(V²·log V)`. (Result is
|
|
46
|
+
still cached per ruleset, so this only matters the first time a large graph is evaluated.)
|
|
47
|
+
|
|
48
|
+
### Internal
|
|
49
|
+
- `cli._read_json` closes the file it opens (was leaked); `cli` content lookup calls
|
|
50
|
+
`categories()` once instead of twice.
|
|
51
|
+
- `assembler.assemble_inputs` copies the ability-score dict once up front rather than on each
|
|
52
|
+
increase.
|
|
53
|
+
|
|
13
54
|
## [0.23.0] — 2026-06-05
|
|
14
55
|
|
|
15
56
|
### Added
|
|
@@ -413,7 +454,9 @@ from a working application.
|
|
|
413
454
|
Pure (pydantic + stdlib); no application/framework coupling. Rules content derives
|
|
414
455
|
from the SRD 5.2 (CC-BY-4.0); see NOTICE.
|
|
415
456
|
|
|
416
|
-
[Unreleased]: https://github.com/sligara7/dndwright/compare/v0.23.
|
|
457
|
+
[Unreleased]: https://github.com/sligara7/dndwright/compare/v0.23.2...HEAD
|
|
458
|
+
[0.23.2]: https://github.com/sligara7/dndwright/compare/v0.23.1...v0.23.2
|
|
459
|
+
[0.23.1]: https://github.com/sligara7/dndwright/compare/v0.23.0...v0.23.1
|
|
417
460
|
[0.23.0]: https://github.com/sligara7/dndwright/compare/v0.22.0...v0.23.0
|
|
418
461
|
[0.22.0]: https://github.com/sligara7/dndwright/compare/v0.21.0...v0.22.0
|
|
419
462
|
[0.21.0]: https://github.com/sligara7/dndwright/compare/v0.20.0...v0.21.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dndwright
|
|
3
|
-
Version: 0.23.
|
|
3
|
+
Version: 0.23.2
|
|
4
4
|
Summary: Domain-neutral D&D 5e (2024) rules & character-sheet computation engine: a data-driven DAG of formulas (ability mods, proficiency, spell DC/slots, HP, AC).
|
|
5
5
|
Project-URL: Homepage, https://github.com/sligara7/dndwright
|
|
6
6
|
Project-URL: Repository, https://github.com/sligara7/dndwright
|
|
@@ -161,6 +161,43 @@ and serialisable — not buried in imperative code. `DND_5E_2024_RULESET` is a 1
|
|
|
161
161
|
<img alt="The dndwright computation graph: ability scores, level, class and equipment flow through ability modifiers and proficiency bonus to saves, skills, spell DC/attack, spell slots, HP, AC and initiative" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/computation-graph.svg">
|
|
162
162
|
</p>
|
|
163
163
|
|
|
164
|
+
### Composable — snap mini-graphs onto the ruleset
|
|
165
|
+
|
|
166
|
+
Items, feats and species traits are themselves tiny graphs. `compose()` merges a
|
|
167
|
+
`Component`'s nodes and contributions onto a base ruleset and returns a new, larger
|
|
168
|
+
`Ruleset` — the base is never mutated. Because each contribution keeps its target node's
|
|
169
|
+
**id**, every existing edge downstream re-derives for free: a `set`/`add`/`union` on one
|
|
170
|
+
node ripples out to every modifier, save, skill and attack that depends on it.
|
|
171
|
+
|
|
172
|
+
<p align="center">
|
|
173
|
+
<img alt="Components are lego-style mini-graphs that snap onto the dndwright ruleset: a Belt of Giant Strength sets the Strength score, a Ring of Protection adds to Armor Class, and Dwarven Resilience unions in poison resistance — compose() merges them and one snap-in recomputes the whole downstream subtree (strength modifier to athletics, saving throws and melee attack)" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/compose.svg">
|
|
174
|
+
</p>
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from dndwright import DND_5E_2024_RULESET, compose, modifier
|
|
178
|
+
ring = modifier("ring_of_protection", target="armor_class", amount=1)
|
|
179
|
+
rs = compose(DND_5E_2024_RULESET, ring) # base untouched; AC now aggregates the +1
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Re-skin for any setting — theme scaling
|
|
183
|
+
|
|
184
|
+
The same engine runs sci-fi, modern-warfare, steampunk or cosmic-horror. A `ThemeScalingLayer`
|
|
185
|
+
folds three kinds of override onto a ruleset via `apply_theme_scaling()` (pure, like `compose`):
|
|
186
|
+
`input_overrides` re-baseline a node's default value, `lookup_overrides` deep-merge into the
|
|
187
|
+
lookup tables (so `plate` armour can read AC 19 instead of 18), and `flavor_renames` relabel
|
|
188
|
+
terms for display **without ever changing a computed value**. The graph's shape never changes —
|
|
189
|
+
only its numbers and names.
|
|
190
|
+
|
|
191
|
+
<p align="center">
|
|
192
|
+
<img alt="dndwright theme scaling: one computation graph re-skinned per setting. A ThemeScalingLayer applies input_overrides (re-baseline node defaults), lookup_overrides (merge tables like armor AC and weapon ranges) and flavor_renames (display labels only). The same plate armor node emerges as 'plate AC 18' in traditional D&D, 'tactical body armor AC 18' in modern warfare, 'power armor AC 19' in sci-fi, and 'clockwork full-plate AC 19' in steampunk" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/theme-scaling.svg">
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
from dndwright import DND_5E_2024_RULESET, apply_theme_scaling, get_theme_scaling
|
|
197
|
+
rs = apply_theme_scaling(DND_5E_2024_RULESET, get_theme_scaling("sci_fi"))
|
|
198
|
+
rs.lookup_tables["armor_base_ac"]["plate"] # 19 (base is still 18, untouched)
|
|
199
|
+
```
|
|
200
|
+
|
|
164
201
|
## What's inside
|
|
165
202
|
|
|
166
203
|
| Component | What it does |
|
|
@@ -172,6 +209,7 @@ and serialisable — not buried in imperative code. `DND_5E_2024_RULESET` is a 1
|
|
|
172
209
|
| `validate_ruleset` / `assert_valid_ruleset` | Static integrity check for a ruleset (unknown ops, cycles, dangling refs) — catch authoring errors before evaluation. |
|
|
173
210
|
| `compose` / `modifier` / `Component` | Snap mini-graphs (items/feats/traits) onto a ruleset; downstream values cascade. |
|
|
174
211
|
| `component_from_content` | Build a `Component` from a bundled item/feat's `component` field — magic items & feats as data that snap onto a character (constant, dynamic, player-chosen, or *conditional* effects). |
|
|
212
|
+
| `apply_theme_scaling` / `ThemeScalingLayer` / `get_theme_scaling` | Re-skin the ruleset for any setting (sci-fi, modern, steampunk, …): override node defaults & lookup tables and re-flavor names, same graph shape. `PREDEFINED_THEME_SCALING` ships ready-made themes. |
|
|
175
213
|
| `to_mermaid` / `to_dot` | Render the computation DAG as Mermaid or Graphviz DOT — *see* the dependency graph. |
|
|
176
214
|
| `dndwright.dice` | Typed dice engine: parse/roll 5e expressions, attacks, saves, damage, stat arrays. |
|
|
177
215
|
| `dndwright.combat` | Pure combat rules over a frozen `CombatantState`: damage, temp HP, healing, death saves. |
|
|
@@ -128,6 +128,43 @@ and serialisable — not buried in imperative code. `DND_5E_2024_RULESET` is a 1
|
|
|
128
128
|
<img alt="The dndwright computation graph: ability scores, level, class and equipment flow through ability modifiers and proficiency bonus to saves, skills, spell DC/attack, spell slots, HP, AC and initiative" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/computation-graph.svg">
|
|
129
129
|
</p>
|
|
130
130
|
|
|
131
|
+
### Composable — snap mini-graphs onto the ruleset
|
|
132
|
+
|
|
133
|
+
Items, feats and species traits are themselves tiny graphs. `compose()` merges a
|
|
134
|
+
`Component`'s nodes and contributions onto a base ruleset and returns a new, larger
|
|
135
|
+
`Ruleset` — the base is never mutated. Because each contribution keeps its target node's
|
|
136
|
+
**id**, every existing edge downstream re-derives for free: a `set`/`add`/`union` on one
|
|
137
|
+
node ripples out to every modifier, save, skill and attack that depends on it.
|
|
138
|
+
|
|
139
|
+
<p align="center">
|
|
140
|
+
<img alt="Components are lego-style mini-graphs that snap onto the dndwright ruleset: a Belt of Giant Strength sets the Strength score, a Ring of Protection adds to Armor Class, and Dwarven Resilience unions in poison resistance — compose() merges them and one snap-in recomputes the whole downstream subtree (strength modifier to athletics, saving throws and melee attack)" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/compose.svg">
|
|
141
|
+
</p>
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
from dndwright import DND_5E_2024_RULESET, compose, modifier
|
|
145
|
+
ring = modifier("ring_of_protection", target="armor_class", amount=1)
|
|
146
|
+
rs = compose(DND_5E_2024_RULESET, ring) # base untouched; AC now aggregates the +1
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
### Re-skin for any setting — theme scaling
|
|
150
|
+
|
|
151
|
+
The same engine runs sci-fi, modern-warfare, steampunk or cosmic-horror. A `ThemeScalingLayer`
|
|
152
|
+
folds three kinds of override onto a ruleset via `apply_theme_scaling()` (pure, like `compose`):
|
|
153
|
+
`input_overrides` re-baseline a node's default value, `lookup_overrides` deep-merge into the
|
|
154
|
+
lookup tables (so `plate` armour can read AC 19 instead of 18), and `flavor_renames` relabel
|
|
155
|
+
terms for display **without ever changing a computed value**. The graph's shape never changes —
|
|
156
|
+
only its numbers and names.
|
|
157
|
+
|
|
158
|
+
<p align="center">
|
|
159
|
+
<img alt="dndwright theme scaling: one computation graph re-skinned per setting. A ThemeScalingLayer applies input_overrides (re-baseline node defaults), lookup_overrides (merge tables like armor AC and weapon ranges) and flavor_renames (display labels only). The same plate armor node emerges as 'plate AC 18' in traditional D&D, 'tactical body armor AC 18' in modern warfare, 'power armor AC 19' in sci-fi, and 'clockwork full-plate AC 19' in steampunk" width="760" src="https://raw.githubusercontent.com/sligara7/dndwright/main/assets/theme-scaling.svg">
|
|
160
|
+
</p>
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
from dndwright import DND_5E_2024_RULESET, apply_theme_scaling, get_theme_scaling
|
|
164
|
+
rs = apply_theme_scaling(DND_5E_2024_RULESET, get_theme_scaling("sci_fi"))
|
|
165
|
+
rs.lookup_tables["armor_base_ac"]["plate"] # 19 (base is still 18, untouched)
|
|
166
|
+
```
|
|
167
|
+
|
|
131
168
|
## What's inside
|
|
132
169
|
|
|
133
170
|
| Component | What it does |
|
|
@@ -139,6 +176,7 @@ and serialisable — not buried in imperative code. `DND_5E_2024_RULESET` is a 1
|
|
|
139
176
|
| `validate_ruleset` / `assert_valid_ruleset` | Static integrity check for a ruleset (unknown ops, cycles, dangling refs) — catch authoring errors before evaluation. |
|
|
140
177
|
| `compose` / `modifier` / `Component` | Snap mini-graphs (items/feats/traits) onto a ruleset; downstream values cascade. |
|
|
141
178
|
| `component_from_content` | Build a `Component` from a bundled item/feat's `component` field — magic items & feats as data that snap onto a character (constant, dynamic, player-chosen, or *conditional* effects). |
|
|
179
|
+
| `apply_theme_scaling` / `ThemeScalingLayer` / `get_theme_scaling` | Re-skin the ruleset for any setting (sci-fi, modern, steampunk, …): override node defaults & lookup tables and re-flavor names, same graph shape. `PREDEFINED_THEME_SCALING` ships ready-made themes. |
|
|
142
180
|
| `to_mermaid` / `to_dot` | Render the computation DAG as Mermaid or Graphviz DOT — *see* the dependency graph. |
|
|
143
181
|
| `dndwright.dice` | Typed dice engine: parse/roll 5e expressions, attacks, saves, damage, stat arrays. |
|
|
144
182
|
| `dndwright.combat` | Pure combat rules over a frozen `CombatantState`: damage, temp HP, healing, death saves. |
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 480" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#0f172a"/>
|
|
5
|
+
<stop offset="1" stop-color="#111827"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
8
|
+
<path d="M0 0 L10 5 L0 10 z" fill="#64748b"/>
|
|
9
|
+
</marker>
|
|
10
|
+
<marker id="arrowT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
11
|
+
<path d="M0 0 L10 5 L0 10 z" fill="#2dd4bf"/>
|
|
12
|
+
</marker>
|
|
13
|
+
<style>
|
|
14
|
+
.lbl { fill:#e2e8f0; font-size:13px; }
|
|
15
|
+
.sub { fill:#94a3b8; font-size:11px; }
|
|
16
|
+
.node { fill:#1e293b; stroke-width:1.5; }
|
|
17
|
+
.brick { fill:#1e293b; stroke-width:1.6; }
|
|
18
|
+
.layer { fill:#94a3b8; font-size:12px; letter-spacing:.14em; text-transform:uppercase; }
|
|
19
|
+
.edge { stroke:#475569; stroke-width:1.5; fill:none; }
|
|
20
|
+
.casc { stroke:#2dd4bf; stroke-width:1.8; fill:none; }
|
|
21
|
+
.mode { fill:#7dd3fc; font-size:11px; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
22
|
+
.pill { fill:#0b1220; }
|
|
23
|
+
.note { fill:#2dd4bf; font-size:11.5px; }
|
|
24
|
+
</style>
|
|
25
|
+
</defs>
|
|
26
|
+
|
|
27
|
+
<rect x="0" y="0" width="900" height="480" rx="16" fill="url(#bg)"/>
|
|
28
|
+
|
|
29
|
+
<text x="36" y="46" fill="#f8fafc" font-size="26" font-weight="700">dndwright</text>
|
|
30
|
+
<text x="36" y="72" fill="#94a3b8" font-size="15">Components are mini-graphs that snap onto the ruleset — <tspan fill="#7dd3fc" font-family="ui-monospace, Menlo, monospace">compose()</tspan> merges them and downstream values recompute.</text>
|
|
31
|
+
|
|
32
|
+
<!-- column captions -->
|
|
33
|
+
<text class="layer" x="140" y="112" text-anchor="middle">Components</text>
|
|
34
|
+
<text class="layer" x="418" y="112" text-anchor="middle">Base graph · targets</text>
|
|
35
|
+
<text class="layer" x="788" y="112" text-anchor="middle">Cascades to</text>
|
|
36
|
+
|
|
37
|
+
<!-- ============ edges (behind nodes) ============ -->
|
|
38
|
+
<!-- component -> target (snap-in) -->
|
|
39
|
+
<path class="edge" marker-end="url(#arrow)" d="M240 168 C290 168 296 168 330 168"/>
|
|
40
|
+
<path class="edge" marker-end="url(#arrow)" d="M240 276 C290 276 296 276 330 276"/>
|
|
41
|
+
<path class="edge" marker-end="url(#arrow)" d="M240 384 C290 384 296 384 330 384"/>
|
|
42
|
+
<!-- strength score -> strength mod -> outputs (cascade) -->
|
|
43
|
+
<path class="casc" marker-end="url(#arrowT)" d="M500 168 C528 168 532 175 552 175"/>
|
|
44
|
+
<path class="casc" marker-end="url(#arrowT)" d="M708 175 C730 175 726 130 744 130"/>
|
|
45
|
+
<path class="casc" marker-end="url(#arrowT)" d="M708 175 C730 175 730 175 744 175"/>
|
|
46
|
+
<path class="casc" marker-end="url(#arrowT)" d="M708 175 C730 175 726 220 744 220"/>
|
|
47
|
+
|
|
48
|
+
<!-- mode labels on snap-in edges -->
|
|
49
|
+
<g>
|
|
50
|
+
<rect class="pill" x="270" y="150" width="34" height="17" rx="5"/>
|
|
51
|
+
<text class="mode" x="277" y="162">set</text>
|
|
52
|
+
<rect class="pill" x="268" y="258" width="40" height="17" rx="5"/>
|
|
53
|
+
<text class="mode" x="275" y="270">add</text>
|
|
54
|
+
<rect class="pill" x="262" y="366" width="52" height="17" rx="5"/>
|
|
55
|
+
<text class="mode" x="269" y="378">union</text>
|
|
56
|
+
</g>
|
|
57
|
+
|
|
58
|
+
<!-- ============ component bricks (lego) ============ -->
|
|
59
|
+
<!-- Belt of Giant Strength (amber) -->
|
|
60
|
+
<g>
|
|
61
|
+
<rect x="56" y="135" width="16" height="11" rx="4" fill="#f59e0b"/>
|
|
62
|
+
<rect x="96" y="135" width="16" height="11" rx="4" fill="#f59e0b"/>
|
|
63
|
+
<rect x="136" y="135" width="16" height="11" rx="4" fill="#f59e0b"/>
|
|
64
|
+
<rect x="176" y="135" width="16" height="11" rx="4" fill="#f59e0b"/>
|
|
65
|
+
<rect class="brick" rx="10" x="40" y="140" width="200" height="56" stroke="#f59e0b"/>
|
|
66
|
+
<text class="lbl" x="58" y="166">Belt of Giant Strength</text>
|
|
67
|
+
<text class="sub" x="58" y="184">Strength score = 25</text>
|
|
68
|
+
</g>
|
|
69
|
+
<!-- Ring of Protection (rose) -->
|
|
70
|
+
<g>
|
|
71
|
+
<rect x="56" y="243" width="16" height="11" rx="4" fill="#fb7185"/>
|
|
72
|
+
<rect x="96" y="243" width="16" height="11" rx="4" fill="#fb7185"/>
|
|
73
|
+
<rect x="136" y="243" width="16" height="11" rx="4" fill="#fb7185"/>
|
|
74
|
+
<rect x="176" y="243" width="16" height="11" rx="4" fill="#fb7185"/>
|
|
75
|
+
<rect class="brick" rx="10" x="40" y="248" width="200" height="56" stroke="#fb7185"/>
|
|
76
|
+
<text class="lbl" x="58" y="274">Ring of Protection</text>
|
|
77
|
+
<text class="sub" x="58" y="292">+1 Armor Class</text>
|
|
78
|
+
</g>
|
|
79
|
+
<!-- Dwarven Resilience (teal) -->
|
|
80
|
+
<g>
|
|
81
|
+
<rect x="56" y="351" width="16" height="11" rx="4" fill="#2dd4bf"/>
|
|
82
|
+
<rect x="96" y="351" width="16" height="11" rx="4" fill="#2dd4bf"/>
|
|
83
|
+
<rect x="136" y="351" width="16" height="11" rx="4" fill="#2dd4bf"/>
|
|
84
|
+
<rect x="176" y="351" width="16" height="11" rx="4" fill="#2dd4bf"/>
|
|
85
|
+
<rect class="brick" rx="10" x="40" y="356" width="200" height="56" stroke="#2dd4bf"/>
|
|
86
|
+
<text class="lbl" x="58" y="382">Dwarven Resilience</text>
|
|
87
|
+
<text class="sub" x="58" y="400">+ poison resistance</text>
|
|
88
|
+
</g>
|
|
89
|
+
|
|
90
|
+
<!-- ============ base-graph target nodes ============ -->
|
|
91
|
+
<g>
|
|
92
|
+
<!-- Strength score (input, becomes an aggregate) -->
|
|
93
|
+
<rect class="node" rx="9" x="330" y="150" width="170" height="36" stroke="#f59e0b"/>
|
|
94
|
+
<text class="lbl" x="346" y="166">Strength score</text>
|
|
95
|
+
<text class="sub" x="346" y="180">now an aggregate node</text>
|
|
96
|
+
|
|
97
|
+
<rect class="node" rx="9" x="330" y="259" width="170" height="34" stroke="#a78bfa"/>
|
|
98
|
+
<text class="lbl" x="346" y="281">Armor Class</text>
|
|
99
|
+
|
|
100
|
+
<rect class="node" rx="9" x="330" y="367" width="170" height="34" stroke="#a78bfa"/>
|
|
101
|
+
<text class="lbl" x="346" y="389">Resistances</text>
|
|
102
|
+
</g>
|
|
103
|
+
|
|
104
|
+
<!-- ============ derived + cascade outputs ============ -->
|
|
105
|
+
<g>
|
|
106
|
+
<rect class="node" rx="9" x="552" y="157" width="156" height="36" stroke="#2dd4bf"/>
|
|
107
|
+
<text class="lbl" x="568" y="179">Strength modifier</text>
|
|
108
|
+
|
|
109
|
+
<rect class="node" rx="9" x="744" y="113" width="140" height="34" stroke="#a78bfa"/>
|
|
110
|
+
<text class="lbl" x="760" y="135">Athletics</text>
|
|
111
|
+
<rect class="node" rx="9" x="744" y="158" width="140" height="34" stroke="#a78bfa"/>
|
|
112
|
+
<text class="lbl" x="760" y="180">STR saving throw</text>
|
|
113
|
+
<rect class="node" rx="9" x="744" y="203" width="140" height="34" stroke="#a78bfa"/>
|
|
114
|
+
<text class="lbl" x="760" y="225">Melee attack</text>
|
|
115
|
+
</g>
|
|
116
|
+
|
|
117
|
+
<text class="note" x="552" y="250">One snap-in recomputes the</text>
|
|
118
|
+
<text class="note" x="552" y="266">whole downstream subtree.</text>
|
|
119
|
+
|
|
120
|
+
<text x="36" y="462" fill="#64748b" font-size="12">compose(base, *components) -> a larger Ruleset · pure (the base graph is never mutated) · the target id is kept, so existing edges cascade</text>
|
|
121
|
+
</svg>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 480" font-family="ui-sans-serif, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#0f172a"/>
|
|
5
|
+
<stop offset="1" stop-color="#111827"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
8
|
+
<path d="M0 0 L10 5 L0 10 z" fill="#64748b"/>
|
|
9
|
+
</marker>
|
|
10
|
+
<marker id="arrowT" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
|
11
|
+
<path d="M0 0 L10 5 L0 10 z" fill="#2dd4bf"/>
|
|
12
|
+
</marker>
|
|
13
|
+
<style>
|
|
14
|
+
.lbl { fill:#e2e8f0; font-size:13px; }
|
|
15
|
+
.sub { fill:#94a3b8; font-size:11px; }
|
|
16
|
+
.node { fill:#1e293b; stroke-width:1.5; }
|
|
17
|
+
.layer { fill:#94a3b8; font-size:12px; letter-spacing:.14em; text-transform:uppercase; }
|
|
18
|
+
.edge { stroke:#475569; stroke-width:1.5; fill:none; }
|
|
19
|
+
.casc { stroke:#2dd4bf; stroke-width:1.8; fill:none; }
|
|
20
|
+
.mono { font-size:11.5px; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
21
|
+
.res { fill:#7dd3fc; font-size:11px; font-family:ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
22
|
+
</style>
|
|
23
|
+
</defs>
|
|
24
|
+
|
|
25
|
+
<rect x="0" y="0" width="900" height="480" rx="16" fill="url(#bg)"/>
|
|
26
|
+
|
|
27
|
+
<text x="36" y="46" fill="#f8fafc" font-size="26" font-weight="700">dndwright</text>
|
|
28
|
+
<text x="36" y="72" fill="#94a3b8" font-size="15">One graph, any setting — a <tspan fill="#38bdf8">ThemeScalingLayer</tspan> re-baselines values & lookup tables, and re-flavors names.</text>
|
|
29
|
+
|
|
30
|
+
<!-- column captions -->
|
|
31
|
+
<text class="layer" x="140" y="116" text-anchor="middle">Base ruleset</text>
|
|
32
|
+
<text class="layer" x="430" y="116" text-anchor="middle">ThemeScalingLayer</text>
|
|
33
|
+
<text class="layer" x="751" y="116" text-anchor="middle">Same graph, per theme</text>
|
|
34
|
+
|
|
35
|
+
<!-- ============ edges ============ -->
|
|
36
|
+
<!-- base -> lens -->
|
|
37
|
+
<path class="edge" marker-end="url(#arrow)" d="M240 250 C268 250 272 250 296 250"/>
|
|
38
|
+
<!-- lens -> per-theme results (cascade) -->
|
|
39
|
+
<path class="casc" marker-end="url(#arrowT)" d="M560 250 C590 250 592 151 616 151"/>
|
|
40
|
+
<path class="casc" marker-end="url(#arrowT)" d="M560 250 C590 250 592 211 616 211"/>
|
|
41
|
+
<path class="casc" marker-end="url(#arrowT)" d="M560 250 C590 250 592 271 616 271"/>
|
|
42
|
+
<path class="casc" marker-end="url(#arrowT)" d="M560 250 C590 250 592 331 616 331"/>
|
|
43
|
+
|
|
44
|
+
<!-- ============ base ruleset ============ -->
|
|
45
|
+
<g>
|
|
46
|
+
<rect class="node" rx="9" x="40" y="178" width="200" height="52" stroke="#f59e0b"/>
|
|
47
|
+
<text class="lbl" x="58" y="202">armor_base_ac (lookup)</text>
|
|
48
|
+
<text class="res" x="58" y="220">plate = 18</text>
|
|
49
|
+
|
|
50
|
+
<path class="edge" marker-end="url(#arrow)" d="M140 230 L140 268"/>
|
|
51
|
+
|
|
52
|
+
<rect class="node" rx="9" x="40" y="270" width="200" height="44" stroke="#a78bfa"/>
|
|
53
|
+
<text class="lbl" x="58" y="297">Armor Class</text>
|
|
54
|
+
|
|
55
|
+
<text class="sub" x="140" y="338" text-anchor="middle">138-node DAG · formulas as data</text>
|
|
56
|
+
</g>
|
|
57
|
+
|
|
58
|
+
<!-- ============ ThemeScalingLayer (lens) ============ -->
|
|
59
|
+
<g>
|
|
60
|
+
<rect class="node" rx="12" x="296" y="150" width="264" height="212" stroke="#38bdf8"/>
|
|
61
|
+
|
|
62
|
+
<circle cx="318" cy="181" r="4" fill="#2dd4bf"/>
|
|
63
|
+
<text class="mono" x="332" y="185" fill="#7dd3fc">input_overrides</text>
|
|
64
|
+
<text class="sub" x="332" y="200">re-baseline a node's default value</text>
|
|
65
|
+
|
|
66
|
+
<circle cx="318" cy="227" r="4" fill="#2dd4bf"/>
|
|
67
|
+
<text class="mono" x="332" y="231" fill="#7dd3fc">lookup_overrides</text>
|
|
68
|
+
<text class="sub" x="332" y="246">merge tables: armor AC, weapon ranges</text>
|
|
69
|
+
|
|
70
|
+
<circle cx="318" cy="273" r="4" fill="#64748b"/>
|
|
71
|
+
<text class="mono" x="332" y="277" fill="#7dd3fc">flavor_renames</text>
|
|
72
|
+
<text class="sub" x="332" y="292">display label only — never changes math</text>
|
|
73
|
+
|
|
74
|
+
<line x1="316" y1="312" x2="540" y2="312" stroke="#334155" stroke-width="1"/>
|
|
75
|
+
<text class="mono" x="316" y="332" fill="#38bdf8">apply_theme_scaling(rs, layer)</text>
|
|
76
|
+
<text class="sub" x="316" y="350">pure — the base graph is untouched</text>
|
|
77
|
+
</g>
|
|
78
|
+
|
|
79
|
+
<!-- ============ per-theme results ============ -->
|
|
80
|
+
<g>
|
|
81
|
+
<!-- traditional -->
|
|
82
|
+
<rect class="node" rx="9" x="616" y="128" width="266" height="46" stroke="#475569"/>
|
|
83
|
+
<circle cx="634" cy="151" r="4" fill="#94a3b8"/>
|
|
84
|
+
<text class="lbl" x="648" y="148" fill="#94a3b8">Traditional D&D</text>
|
|
85
|
+
<text class="res" x="648" y="165" fill="#cbd5e1">plate · AC 18</text>
|
|
86
|
+
|
|
87
|
+
<!-- modern warfare -->
|
|
88
|
+
<rect class="node" rx="9" x="616" y="188" width="266" height="46" stroke="#f59e0b"/>
|
|
89
|
+
<circle cx="634" cy="211" r="4" fill="#f59e0b"/>
|
|
90
|
+
<text class="lbl" x="648" y="208" fill="#f59e0b">Modern warfare</text>
|
|
91
|
+
<text class="res" x="648" y="225" fill="#cbd5e1">tactical body armor · AC 18</text>
|
|
92
|
+
|
|
93
|
+
<!-- sci-fi -->
|
|
94
|
+
<rect class="node" rx="9" x="616" y="248" width="266" height="46" stroke="#38bdf8"/>
|
|
95
|
+
<circle cx="634" cy="271" r="4" fill="#38bdf8"/>
|
|
96
|
+
<text class="lbl" x="648" y="268" fill="#38bdf8">Sci-fi</text>
|
|
97
|
+
<text class="res" x="648" y="285" fill="#cbd5e1">power armor · AC <tspan fill="#2dd4bf" font-weight="700">19</tspan></text>
|
|
98
|
+
|
|
99
|
+
<!-- steampunk -->
|
|
100
|
+
<rect class="node" rx="9" x="616" y="308" width="266" height="46" stroke="#fb7185"/>
|
|
101
|
+
<circle cx="634" cy="331" r="4" fill="#fb7185"/>
|
|
102
|
+
<text class="lbl" x="648" y="328" fill="#fb7185">Steampunk</text>
|
|
103
|
+
<text class="res" x="648" y="345" fill="#cbd5e1">clockwork full-plate · AC <tspan fill="#2dd4bf" font-weight="700">19</tspan></text>
|
|
104
|
+
</g>
|
|
105
|
+
|
|
106
|
+
<text x="36" y="462" fill="#64748b" font-size="12">precedence: explicit input > themed default > original · flavor_renames never change a value · composes with compose()</text>
|
|
107
|
+
</svg>
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dndwright"
|
|
7
|
-
version = "0.23.
|
|
7
|
+
version = "0.23.2"
|
|
8
8
|
description = "Domain-neutral D&D 5e (2024) rules & character-sheet computation engine: a data-driven DAG of formulas (ability mods, proficiency, spell DC/slots, HP, AC)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -23,7 +23,11 @@ from . import __version__
|
|
|
23
23
|
|
|
24
24
|
def _read_json(path: str) -> Any:
|
|
25
25
|
"""Load JSON from ``path`` ('-' = stdin)."""
|
|
26
|
-
|
|
26
|
+
if path == "-":
|
|
27
|
+
text = sys.stdin.read()
|
|
28
|
+
else:
|
|
29
|
+
with open(path, encoding="utf-8") as f:
|
|
30
|
+
text = f.read()
|
|
27
31
|
return json.loads(text)
|
|
28
32
|
|
|
29
33
|
|
|
@@ -59,11 +63,12 @@ def _cmd_graph(args: argparse.Namespace) -> int:
|
|
|
59
63
|
def _cmd_content(args: argparse.Namespace) -> int:
|
|
60
64
|
from .content import categories, load_content
|
|
61
65
|
|
|
66
|
+
cats = categories()
|
|
62
67
|
if not args.category:
|
|
63
|
-
print("\n".join(
|
|
68
|
+
print("\n".join(cats))
|
|
64
69
|
return 0
|
|
65
|
-
if args.category not in
|
|
66
|
-
print(f"unknown category {args.category!r}; choose from {
|
|
70
|
+
if args.category not in cats:
|
|
71
|
+
print(f"unknown category {args.category!r}; choose from {cats}",
|
|
67
72
|
file=sys.stderr)
|
|
68
73
|
return 2
|
|
69
74
|
items = load_content(args.category)
|
|
@@ -25,7 +25,7 @@ from __future__ import annotations
|
|
|
25
25
|
|
|
26
26
|
import random
|
|
27
27
|
import re
|
|
28
|
-
from dataclasses import dataclass
|
|
28
|
+
from dataclasses import dataclass, replace
|
|
29
29
|
from typing import Protocol
|
|
30
30
|
|
|
31
31
|
# Hard safety bound on reroll/explode loops so a pathological group (e.g. a die
|
|
@@ -64,7 +64,7 @@ class DiceGroup:
|
|
|
64
64
|
if self.drop_lowest:
|
|
65
65
|
s += f"dl{self.drop_lowest}"
|
|
66
66
|
if self.reroll_on:
|
|
67
|
-
s += f"r{','.join(map(str, self.reroll_on))}"
|
|
67
|
+
s += f"{'ro' if self.reroll_once else 'r'}{','.join(map(str, self.reroll_on))}"
|
|
68
68
|
if self.exploding:
|
|
69
69
|
s += "!"
|
|
70
70
|
return s
|
|
@@ -476,7 +476,12 @@ class DiceEngine:
|
|
|
476
476
|
"""Roll damage; on a crit, double the dice counts (the flat modifier is unchanged)."""
|
|
477
477
|
if is_critical:
|
|
478
478
|
parsed = self.parse_expression(expression)
|
|
479
|
-
|
|
479
|
+
# Double each group's die count while preserving its keep/drop/reroll/explode
|
|
480
|
+
# flags — str(DiceGroup) is the canonical, round-trippable serialization, so a
|
|
481
|
+
# crit on e.g. "1d6!+2" stays exploding ("2d6!+2") instead of silently dropping it.
|
|
482
|
+
doubled_groups = [
|
|
483
|
+
str(replace(g, count=g.count * 2)) for g in parsed.dice_groups
|
|
484
|
+
]
|
|
480
485
|
if doubled_groups:
|
|
481
486
|
doubled_expr = "+".join(doubled_groups)
|
|
482
487
|
if parsed.modifier:
|
|
@@ -496,7 +496,7 @@ def _build_species_traits(species_data: dict) -> list[dict]:
|
|
|
496
496
|
for c in sp_immunities.get("conditions", []):
|
|
497
497
|
result.append({"name": f"{c} Immunity", "category": "immunity"})
|
|
498
498
|
for d in sp_immunities.get("damage", []):
|
|
499
|
-
result.append({"name": f"{d} Immunity", "category": "
|
|
499
|
+
result.append({"name": f"{d} Immunity", "category": "immunity"})
|
|
500
500
|
elif isinstance(sp_immunities, list):
|
|
501
501
|
for im in sp_immunities:
|
|
502
502
|
result.append({"name": f"{im} Immunity", "category": "immunity"})
|
|
@@ -67,7 +67,8 @@ def assemble_character_inputs(
|
|
|
67
67
|
Dict keyed by computation node IDs, ready for evaluate().
|
|
68
68
|
"""
|
|
69
69
|
inputs: dict[str, Any] = {}
|
|
70
|
-
|
|
70
|
+
# Copy once up front so the caller's dict is never mutated by the increases below.
|
|
71
|
+
scores = dict(ability_scores) if ability_scores else {
|
|
71
72
|
"strength": 10, "dexterity": 10, "constitution": 10,
|
|
72
73
|
"intelligence": 10, "wisdom": 10, "charisma": 10,
|
|
73
74
|
}
|
|
@@ -78,7 +79,6 @@ def assemble_character_inputs(
|
|
|
78
79
|
if background_mechanics:
|
|
79
80
|
for ability, increase in background_mechanics.ability_score_increases.items():
|
|
80
81
|
if ability in scores:
|
|
81
|
-
scores = dict(scores) # copy to avoid mutating original
|
|
82
82
|
scores[ability] = scores[ability] + increase
|
|
83
83
|
|
|
84
84
|
# ------------------------------------------------------------------
|
|
@@ -88,7 +88,6 @@ def assemble_character_inputs(
|
|
|
88
88
|
for feat in feats:
|
|
89
89
|
for ability, increase in feat.grants_ability_increase.items():
|
|
90
90
|
if ability in scores:
|
|
91
|
-
scores = dict(scores)
|
|
92
91
|
scores[ability] = scores[ability] + increase
|
|
93
92
|
|
|
94
93
|
# ------------------------------------------------------------------
|
|
@@ -118,6 +118,14 @@ def _aggregate_nodes(
|
|
|
118
118
|
base = [ov_id]
|
|
119
119
|
|
|
120
120
|
if unions:
|
|
121
|
+
if adds or sets:
|
|
122
|
+
# union is for set-valued channels (resistances, languages); add/set are
|
|
123
|
+
# numeric. Mixing them on one target is a modelling error — fail loudly
|
|
124
|
+
# rather than silently dropping the add/set contributions.
|
|
125
|
+
raise ValueError(
|
|
126
|
+
f"target {target!r} mixes union with add/set contributions; "
|
|
127
|
+
"a target must be either set-valued or numeric, not both"
|
|
128
|
+
)
|
|
121
129
|
formula = FormulaSpec(op="union", args=base + unions)
|
|
122
130
|
elif adds and sets:
|
|
123
131
|
sum_id = f"{target}.__sum__"
|
|
@@ -10,6 +10,7 @@ No I/O, no side effects, trivially testable.
|
|
|
10
10
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
+
import heapq
|
|
13
14
|
import weakref
|
|
14
15
|
from typing import Any
|
|
15
16
|
|
|
@@ -59,19 +60,20 @@ def _topological_sort(nodes: dict[str, ComputationNode]) -> list[str]:
|
|
|
59
60
|
if dep in dependents:
|
|
60
61
|
dependents[dep].append(nid)
|
|
61
62
|
|
|
62
|
-
# Start with nodes that have no dependencies
|
|
63
|
+
# Start with nodes that have no dependencies. A min-heap keeps the pop order
|
|
64
|
+
# deterministic (always the lexicographically smallest ready node) in O(log n)
|
|
65
|
+
# per step, instead of re-sorting the whole queue on every iteration.
|
|
63
66
|
queue = [nid for nid, deg in in_degree.items() if deg == 0]
|
|
67
|
+
heapq.heapify(queue)
|
|
64
68
|
order: list[str] = []
|
|
65
69
|
|
|
66
70
|
while queue:
|
|
67
|
-
|
|
68
|
-
queue.sort()
|
|
69
|
-
nid = queue.pop(0)
|
|
71
|
+
nid = heapq.heappop(queue)
|
|
70
72
|
order.append(nid)
|
|
71
73
|
for dep_nid in dependents[nid]:
|
|
72
74
|
in_degree[dep_nid] -= 1
|
|
73
75
|
if in_degree[dep_nid] == 0:
|
|
74
|
-
|
|
76
|
+
heapq.heappush(queue, dep_nid)
|
|
75
77
|
|
|
76
78
|
if len(order) != len(nodes):
|
|
77
79
|
missing = set(nodes.keys()) - set(order)
|