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.
Files changed (108) hide show
  1. {dndwright-0.23.0 → dndwright-0.23.2}/CHANGELOG.md +44 -1
  2. {dndwright-0.23.0 → dndwright-0.23.2}/PKG-INFO +39 -1
  3. {dndwright-0.23.0 → dndwright-0.23.2}/README.md +38 -0
  4. dndwright-0.23.2/assets/compose.svg +121 -0
  5. dndwright-0.23.2/assets/theme-scaling.svg +107 -0
  6. {dndwright-0.23.0 → dndwright-0.23.2}/pyproject.toml +1 -1
  7. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/__init__.py +1 -1
  8. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/cli.py +9 -4
  9. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/dice/engine.py +8 -3
  10. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/adapters.py +1 -1
  11. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/assembler.py +2 -3
  12. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/compose.py +8 -0
  13. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/evaluator.py +7 -5
  14. dndwright-0.23.2/tests/test_api_contract.py +331 -0
  15. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_dice.py +16 -0
  16. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_species_components.py +12 -0
  17. dndwright-0.23.0/tests/test_api_contract.py +0 -114
  18. {dndwright-0.23.0 → dndwright-0.23.2}/.github/workflows/ci.yml +0 -0
  19. {dndwright-0.23.0 → dndwright-0.23.2}/.github/workflows/publish.yml +0 -0
  20. {dndwright-0.23.0 → dndwright-0.23.2}/.gitignore +0 -0
  21. {dndwright-0.23.0 → dndwright-0.23.2}/LICENSE +0 -0
  22. {dndwright-0.23.0 → dndwright-0.23.2}/NOTICE +0 -0
  23. {dndwright-0.23.0 → dndwright-0.23.2}/RELEASING.md +0 -0
  24. {dndwright-0.23.0 → dndwright-0.23.2}/assets/combat-flow.svg +0 -0
  25. {dndwright-0.23.0 → dndwright-0.23.2}/assets/computation-graph.svg +0 -0
  26. {dndwright-0.23.0 → dndwright-0.23.2}/assets/dice.svg +0 -0
  27. {dndwright-0.23.0 → dndwright-0.23.2}/assets/overview.svg +0 -0
  28. {dndwright-0.23.0 → dndwright-0.23.2}/examples/README.md +0 -0
  29. {dndwright-0.23.0 → dndwright-0.23.2}/examples/combat.py +0 -0
  30. {dndwright-0.23.0 → dndwright-0.23.2}/examples/compose.py +0 -0
  31. {dndwright-0.23.0 → dndwright-0.23.2}/examples/conditions.py +0 -0
  32. {dndwright-0.23.0 → dndwright-0.23.2}/examples/content_components.py +0 -0
  33. {dndwright-0.23.0 → dndwright-0.23.2}/examples/custom_operation.py +0 -0
  34. {dndwright-0.23.0 → dndwright-0.23.2}/examples/dice.py +0 -0
  35. {dndwright-0.23.0 → dndwright-0.23.2}/examples/export_graph.py +0 -0
  36. {dndwright-0.23.0 → dndwright-0.23.2}/examples/feat_components.py +0 -0
  37. {dndwright-0.23.0 → dndwright-0.23.2}/examples/gated_contributions.py +0 -0
  38. {dndwright-0.23.0 → dndwright-0.23.2}/examples/initiative.py +0 -0
  39. {dndwright-0.23.0 → dndwright-0.23.2}/examples/multiclass.py +0 -0
  40. {dndwright-0.23.0 → dndwright-0.23.2}/examples/quickstart.py +0 -0
  41. {dndwright-0.23.0 → dndwright-0.23.2}/examples/stat_diff.py +0 -0
  42. {dndwright-0.23.0 → dndwright-0.23.2}/examples/weapon_attack.py +0 -0
  43. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/__init__.py +0 -0
  44. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/combat.py +0 -0
  45. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/conditions.py +0 -0
  46. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/initiative.py +0 -0
  47. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/combat/weapons.py +0 -0
  48. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/__init__.py +0 -0
  49. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/armor.json +0 -0
  50. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/backgrounds.json +0 -0
  51. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/classes.json +0 -0
  52. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/conditions.json +0 -0
  53. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/creatures.json +0 -0
  54. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/feats.json +0 -0
  55. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/generate.py +0 -0
  56. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/magic_items.json +0 -0
  57. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/models.py +0 -0
  58. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/species.json +0 -0
  59. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/spells.json +0 -0
  60. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/content/weapons.json +0 -0
  61. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/dice/__init__.py +0 -0
  62. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/__init__.py +0 -0
  63. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/dnd.yaml +0 -0
  64. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/ontology/loader.py +0 -0
  65. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/py.typed +0 -0
  66. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/__init__.py +0 -0
  67. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/character_evaluator.py +0 -0
  68. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/components.py +0 -0
  69. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/dnd_5e_2024.py +0 -0
  70. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/export.py +0 -0
  71. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/lookup_tables.py +0 -0
  72. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/operations.py +0 -0
  73. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/schema.py +0 -0
  74. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/theme_scaling.py +0 -0
  75. {dndwright-0.23.0 → dndwright-0.23.2}/src/dndwright/rules/validation.py +0 -0
  76. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_background_components.py +0 -0
  77. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_backgrounds.py +0 -0
  78. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_character_theme_scaling.py +0 -0
  79. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_classes.py +0 -0
  80. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_cli.py +0 -0
  81. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_combat.py +0 -0
  82. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_combat_api_contract.py +0 -0
  83. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_compose.py +0 -0
  84. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_conditions.py +0 -0
  85. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content.py +0 -0
  86. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content_components.py +0 -0
  87. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_content_models.py +0 -0
  88. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_creatures.py +0 -0
  89. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_defenses_and_serialization.py +0 -0
  90. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_dice_api_contract.py +0 -0
  91. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_engine.py +0 -0
  92. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_evaluator_cache.py +0 -0
  93. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_examples.py +0 -0
  94. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_export.py +0 -0
  95. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_feat_components.py +0 -0
  96. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_gated_contributions.py +0 -0
  97. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_initiative.py +0 -0
  98. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_input_validation.py +0 -0
  99. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_ontology.py +0 -0
  100. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_operations_registry.py +0 -0
  101. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_properties.py +0 -0
  102. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_public_graph_api.py +0 -0
  103. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_spell_components.py +0 -0
  104. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_spells.py +0 -0
  105. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_theme_scaling.py +0 -0
  106. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_validation.py +0 -0
  107. {dndwright-0.23.0 → dndwright-0.23.2}/tests/test_weapon_attack.py +0 -0
  108. {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.0...HEAD
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.0
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) -&gt; 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 &amp; 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&amp;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 &gt; themed default &gt; 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.0"
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"
@@ -100,7 +100,7 @@ from .rules.validation import (
100
100
  validate_ruleset,
101
101
  )
102
102
 
103
- __version__ = "0.23.0"
103
+ __version__ = "0.23.2"
104
104
 
105
105
  __all__ = [
106
106
  # high-level (dict in -> computed sheet out)
@@ -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
- text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8").read()
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(categories()))
68
+ print("\n".join(cats))
64
69
  return 0
65
- if args.category not in categories():
66
- print(f"unknown category {args.category!r}; choose from {categories()}",
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
- doubled_groups = [f"{g.count * 2}d{g.sides}" for g in parsed.dice_groups]
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": "resistance"})
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
- scores = ability_scores or {
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
- # Sort for deterministic order
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
- queue.append(dep_nid)
76
+ heapq.heappush(queue, dep_nid)
75
77
 
76
78
  if len(order) != len(nodes):
77
79
  missing = set(nodes.keys()) - set(order)