dndwright 0.23.2__tar.gz → 0.25.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 (110) hide show
  1. {dndwright-0.23.2 → dndwright-0.25.2}/CHANGELOG.md +60 -0
  2. {dndwright-0.23.2 → dndwright-0.25.2}/PKG-INFO +2 -1
  3. {dndwright-0.23.2 → dndwright-0.25.2}/README.md +1 -0
  4. {dndwright-0.23.2 → dndwright-0.25.2}/pyproject.toml +1 -1
  5. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/__init__.py +20 -1
  6. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/adapters.py +66 -1
  7. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/assembler.py +1 -0
  8. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/dnd_5e_2024.py +16 -2
  9. dndwright-0.25.2/src/dndwright/rules/homebrew_validator.py +599 -0
  10. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/operations.py +5 -3
  11. dndwright-0.25.2/tests/test_adapters_bugs.py +221 -0
  12. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_api_contract.py +17 -0
  13. dndwright-0.25.2/tests/test_homebrew_validator.py +564 -0
  14. {dndwright-0.23.2 → dndwright-0.25.2}/.github/workflows/ci.yml +0 -0
  15. {dndwright-0.23.2 → dndwright-0.25.2}/.github/workflows/publish.yml +0 -0
  16. {dndwright-0.23.2 → dndwright-0.25.2}/.gitignore +0 -0
  17. {dndwright-0.23.2 → dndwright-0.25.2}/LICENSE +0 -0
  18. {dndwright-0.23.2 → dndwright-0.25.2}/NOTICE +0 -0
  19. {dndwright-0.23.2 → dndwright-0.25.2}/RELEASING.md +0 -0
  20. {dndwright-0.23.2 → dndwright-0.25.2}/assets/combat-flow.svg +0 -0
  21. {dndwright-0.23.2 → dndwright-0.25.2}/assets/compose.svg +0 -0
  22. {dndwright-0.23.2 → dndwright-0.25.2}/assets/computation-graph.svg +0 -0
  23. {dndwright-0.23.2 → dndwright-0.25.2}/assets/dice.svg +0 -0
  24. {dndwright-0.23.2 → dndwright-0.25.2}/assets/overview.svg +0 -0
  25. {dndwright-0.23.2 → dndwright-0.25.2}/assets/theme-scaling.svg +0 -0
  26. {dndwright-0.23.2 → dndwright-0.25.2}/examples/README.md +0 -0
  27. {dndwright-0.23.2 → dndwright-0.25.2}/examples/combat.py +0 -0
  28. {dndwright-0.23.2 → dndwright-0.25.2}/examples/compose.py +0 -0
  29. {dndwright-0.23.2 → dndwright-0.25.2}/examples/conditions.py +0 -0
  30. {dndwright-0.23.2 → dndwright-0.25.2}/examples/content_components.py +0 -0
  31. {dndwright-0.23.2 → dndwright-0.25.2}/examples/custom_operation.py +0 -0
  32. {dndwright-0.23.2 → dndwright-0.25.2}/examples/dice.py +0 -0
  33. {dndwright-0.23.2 → dndwright-0.25.2}/examples/export_graph.py +0 -0
  34. {dndwright-0.23.2 → dndwright-0.25.2}/examples/feat_components.py +0 -0
  35. {dndwright-0.23.2 → dndwright-0.25.2}/examples/gated_contributions.py +0 -0
  36. {dndwright-0.23.2 → dndwright-0.25.2}/examples/initiative.py +0 -0
  37. {dndwright-0.23.2 → dndwright-0.25.2}/examples/multiclass.py +0 -0
  38. {dndwright-0.23.2 → dndwright-0.25.2}/examples/quickstart.py +0 -0
  39. {dndwright-0.23.2 → dndwright-0.25.2}/examples/stat_diff.py +0 -0
  40. {dndwright-0.23.2 → dndwright-0.25.2}/examples/weapon_attack.py +0 -0
  41. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/cli.py +0 -0
  42. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/combat/__init__.py +0 -0
  43. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/combat/combat.py +0 -0
  44. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/combat/conditions.py +0 -0
  45. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/combat/initiative.py +0 -0
  46. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/combat/weapons.py +0 -0
  47. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/__init__.py +0 -0
  48. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/armor.json +0 -0
  49. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/backgrounds.json +0 -0
  50. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/classes.json +0 -0
  51. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/conditions.json +0 -0
  52. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/creatures.json +0 -0
  53. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/feats.json +0 -0
  54. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/generate.py +0 -0
  55. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/magic_items.json +0 -0
  56. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/models.py +0 -0
  57. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/species.json +0 -0
  58. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/spells.json +0 -0
  59. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/content/weapons.json +0 -0
  60. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/dice/__init__.py +0 -0
  61. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/dice/engine.py +0 -0
  62. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/ontology/__init__.py +0 -0
  63. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/ontology/dnd.yaml +0 -0
  64. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/ontology/loader.py +0 -0
  65. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/py.typed +0 -0
  66. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/__init__.py +0 -0
  67. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/character_evaluator.py +0 -0
  68. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/components.py +0 -0
  69. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/compose.py +0 -0
  70. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/evaluator.py +0 -0
  71. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/export.py +0 -0
  72. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/lookup_tables.py +0 -0
  73. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/schema.py +0 -0
  74. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/theme_scaling.py +0 -0
  75. {dndwright-0.23.2 → dndwright-0.25.2}/src/dndwright/rules/validation.py +0 -0
  76. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_background_components.py +0 -0
  77. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_backgrounds.py +0 -0
  78. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_character_theme_scaling.py +0 -0
  79. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_classes.py +0 -0
  80. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_cli.py +0 -0
  81. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_combat.py +0 -0
  82. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_combat_api_contract.py +0 -0
  83. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_compose.py +0 -0
  84. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_conditions.py +0 -0
  85. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_content.py +0 -0
  86. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_content_components.py +0 -0
  87. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_content_models.py +0 -0
  88. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_creatures.py +0 -0
  89. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_defenses_and_serialization.py +0 -0
  90. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_dice.py +0 -0
  91. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_dice_api_contract.py +0 -0
  92. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_engine.py +0 -0
  93. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_evaluator_cache.py +0 -0
  94. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_examples.py +0 -0
  95. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_export.py +0 -0
  96. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_feat_components.py +0 -0
  97. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_gated_contributions.py +0 -0
  98. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_initiative.py +0 -0
  99. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_input_validation.py +0 -0
  100. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_ontology.py +0 -0
  101. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_operations_registry.py +0 -0
  102. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_properties.py +0 -0
  103. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_public_graph_api.py +0 -0
  104. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_species_components.py +0 -0
  105. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_spell_components.py +0 -0
  106. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_spells.py +0 -0
  107. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_theme_scaling.py +0 -0
  108. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_validation.py +0 -0
  109. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_weapon_attack.py +0 -0
  110. {dndwright-0.23.2 → dndwright-0.25.2}/tests/test_weapons_armor.py +0 -0
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.25.2
4
+
5
+ ### Fixed
6
+ - `validate_class_homebrew` now accepts the canonical `CharClass` shape: dice-string `hit_die`
7
+ (e.g. `"d10"`) and case-insensitive ability names in saving throws. It previously demanded an
8
+ int `hit_die` + lowercase abilities, false-positiving on every valid generated class — and
9
+ contradicting dndwright's own `CharClass` model (`hit_die: str`). Bare-int hit_die and lowercase
10
+ abilities still validate; genuinely-bad data is still caught.
11
+
12
+ ## 0.25.1
13
+
14
+ ### Fixed
15
+ - Hit-die compute/display desync: the displayed hit die now matches the archetype/class override used for HP computation (was showing the raw LLM value).
16
+ - Species natural armor now contributes to AC via a `natural_armor_ac` computation-graph input; AC = max(natural, equipped + dex + magic + shield) per 5e.
17
+
18
+
3
19
  All notable changes to dndwright are documented here. The format follows
4
20
  [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow
5
21
  [Semantic Versioning](https://semver.org/).
@@ -10,6 +26,50 @@ breaking changes; these will always be noted here.
10
26
 
11
27
  ## [Unreleased]
12
28
 
29
+ ## [0.25.0] — 2026-06-13
30
+
31
+ ### Added
32
+ - **Power-budget validator.** `validate_power_budget(species_data, class_data,
33
+ subclass_data, level)` returns budget overage problems (species traits,
34
+ class+subclass features per level, combined species-vs-learned split). Baselines
35
+ derived from dndwright's own SRD 5.2.1 content: 9 species (5 trait max, 3
36
+ high-impact max) and 12 classes (cumulative feature budgets by level). High-impact
37
+ traits (flight, innate casting, resistance, breath weapon) weighted heavier.
38
+ Superman+Batman double-stacking caught at low levels. Exported in `dndwright.__all__`.
39
+ - 17 new unit tests for power-budget coverage (balanced vs over-budget vs edge cases).
40
+
41
+ ### Changed
42
+ - `__all__` expanded: +1 name (`validate_power_budget`).
43
+
44
+ ## [0.24.1] — 2026-06-13
45
+
46
+ ### Documentation
47
+ - Homebrew validators added to README feature table.
48
+
49
+ ## [0.24.0] — 2026-06-13
50
+
51
+ ### Added
52
+ - **Homebrew validation module.** New `rules/homebrew_validator.py` with
53
+ `validate_class_homebrew()`, `validate_species_homebrew()`,
54
+ `validate_subclass_homebrew()`, and `validate_background_homebrew()` — each
55
+ returns a list of human-readable rule-violation strings (empty = legal).
56
+ Structural checks per SRD 5.2.1: hit-die legality (d6/d8/d10/d12),
57
+ saving-throw proficiency pairs (one strong + one weak), archetype and
58
+ spellcasting-type validity, feature-level ranges (1–20, no duplicates),
59
+ species speed limits, background skill counts, and more.
60
+ - `validate_homebrew(component_type, data)` router and `HOMEBREW_VALIDATORS`
61
+ lookup dict. All new names exported in `dndwright.__all__`.
62
+ - 30+ unit tests for all validators, covering legal components (empty
63
+ return), illegal components (each check catches), and the aggregate
64
+ router.
65
+
66
+ ### Changed
67
+ - `__all__` expanded: +7 names (`validate_class_homebrew`,
68
+ `validate_species_homebrew`, `validate_subclass_homebrew`,
69
+ `validate_background_homebrew`, `validate_homebrew`, `HOMEBREW_VALIDATORS`).
70
+ - `tests/test_api_contract.py` `EXPECTED_PUBLIC` and `EXPECTED_SIGNATURES`
71
+ updated for the new exports.
72
+
13
73
  ## [0.23.2] — 2026-06-06
14
74
 
15
75
  ### Documentation
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dndwright
3
- Version: 0.23.2
3
+ Version: 0.25.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
@@ -207,6 +207,7 @@ rs.lookup_tables["armor_base_ac"]["plate"] # 19 (base is still 18, untouched)
207
207
  | `evaluate` / `assemble_character_inputs` / `apply_modifiers` | The lower-level engine. |
208
208
  | `Ruleset` / `ComputationNode` / `FormulaSpec` / `NodeType` | The DAG schema. |
209
209
  | `validate_ruleset` / `assert_valid_ruleset` | Static integrity check for a ruleset (unknown ops, cycles, dangling refs) — catch authoring errors before evaluation. |
210
+ | `validate_class_homebrew` / `validate_species_homebrew` / `validate_subclass_homebrew` / `validate_background_homebrew` / `validate_homebrew` | Validate homebrew class/species/subclass/background data against SRD 5.2.1 structural rules (hit die, save profs, archetype, feature levels, skill counts, speed limits). Returns `list[str]` of violations — empty = legal. |
210
211
  | `compose` / `modifier` / `Component` | Snap mini-graphs (items/feats/traits) onto a ruleset; downstream values cascade. |
211
212
  | `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
213
  | `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. |
@@ -174,6 +174,7 @@ rs.lookup_tables["armor_base_ac"]["plate"] # 19 (base is still 18, untouched)
174
174
  | `evaluate` / `assemble_character_inputs` / `apply_modifiers` | The lower-level engine. |
175
175
  | `Ruleset` / `ComputationNode` / `FormulaSpec` / `NodeType` | The DAG schema. |
176
176
  | `validate_ruleset` / `assert_valid_ruleset` | Static integrity check for a ruleset (unknown ops, cycles, dangling refs) — catch authoring errors before evaluation. |
177
+ | `validate_class_homebrew` / `validate_species_homebrew` / `validate_subclass_homebrew` / `validate_background_homebrew` / `validate_homebrew` | Validate homebrew class/species/subclass/background data against SRD 5.2.1 structural rules (hit die, save profs, archetype, feature levels, skill counts, speed limits). Returns `list[str]` of violations — empty = legal. |
177
178
  | `compose` / `modifier` / `Component` | Snap mini-graphs (items/feats/traits) onto a ruleset; downstream values cascade. |
178
179
  | `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
180
  | `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. |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "dndwright"
7
- version = "0.23.2"
7
+ version = "0.25.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,18 @@ from .rules.validation import (
100
100
  validate_ruleset,
101
101
  )
102
102
 
103
- __version__ = "0.23.2"
103
+ __version__ = "0.25.0"
104
+
105
+ # homebrew validation (structural rules checks on LLM-generated components)
106
+ from .rules.homebrew_validator import (
107
+ validate_class_homebrew,
108
+ validate_species_homebrew,
109
+ validate_subclass_homebrew,
110
+ validate_background_homebrew,
111
+ validate_homebrew,
112
+ validate_power_budget,
113
+ VALIDATORS as HOMEBREW_VALIDATORS,
114
+ )
104
115
 
105
116
  __all__ = [
106
117
  # high-level (dict in -> computed sheet out)
@@ -187,4 +198,12 @@ __all__ = [
187
198
  "Modifier",
188
199
  # dice engine (full typed surface in dndwright.dice)
189
200
  "DiceEngine",
201
+ # homebrew validation (structural rules checks on LLM-generated components)
202
+ "validate_class_homebrew",
203
+ "validate_species_homebrew",
204
+ "validate_subclass_homebrew",
205
+ "validate_background_homebrew",
206
+ "validate_homebrew",
207
+ "validate_power_budget",
208
+ "HOMEBREW_VALIDATORS",
190
209
  ]
@@ -18,6 +18,67 @@ from .lookup_tables import (
18
18
  SPELLCASTING_TYPE_BY_CLASS,
19
19
  )
20
20
 
21
+ import re
22
+
23
+
24
+ import re
25
+
26
+
27
+ def _resolve_hit_die(class_data: dict) -> int:
28
+ """Return the resolved hit die size, applying lookup-table overrides.
29
+
30
+ Mirrors the logic in ``character_data_to_inputs`` lines 51-58 so the display
31
+ value matches the computation.
32
+ """
33
+ hit_die_str = class_data.get("hit_die", "d8")
34
+ hit_die_size = int(hit_die_str.replace("d", "")) if isinstance(hit_die_str, str) else 8
35
+
36
+ class_name = class_data.get("class_name", "").lower()
37
+ archetype = (
38
+ class_data.get("archetype")
39
+ or class_data.get("properties", {}).get("archetype", "")
40
+ )
41
+ if class_name in HIT_DIE_BY_CLASS:
42
+ hit_die_size = HIT_DIE_BY_CLASS[class_name]
43
+ elif archetype in HIT_DIE_BY_ARCHETYPE:
44
+ hit_die_size = HIT_DIE_BY_ARCHETYPE[archetype]
45
+
46
+ return hit_die_size
47
+
48
+
49
+ _NATURAL_ARMOR_KEYWORDS = re.compile(
50
+ r"(carapace|natural armor|chitin|exoskeleton|scales|tough hide|bark skin|"
51
+ r"thick fur|bony plates|armored shell)",
52
+ re.IGNORECASE,
53
+ )
54
+ _AC_VALUE_RE = re.compile(r"\bAC\b.*?(\d+)", re.IGNORECASE)
55
+
56
+
57
+ def _parse_natural_armor(species_data: dict) -> int:
58
+ """Extract natural armor AC from species traits, or 0 if none."""
59
+ # Explicit field (future-proof)
60
+ explicit = species_data.get("natural_armor_ac")
61
+ if isinstance(explicit, int) and explicit > 0:
62
+ return explicit
63
+
64
+ traits: list[dict] = species_data.get("traits", []) or []
65
+ if not isinstance(traits, list):
66
+ return 0
67
+
68
+ for trait in traits:
69
+ if not isinstance(trait, dict):
70
+ continue
71
+ name = (trait.get("name") or "").lower()
72
+ desc = (trait.get("description") or "").lower()
73
+ combined = name + " " + desc
74
+
75
+ if _NATURAL_ARMOR_KEYWORDS.search(combined):
76
+ match = _AC_VALUE_RE.search(combined)
77
+ if match:
78
+ return int(match.group(1))
79
+
80
+ return 0
81
+
21
82
 
22
83
  def character_data_to_inputs(
23
84
  ability_scores: dict[str, int],
@@ -86,6 +147,9 @@ def character_data_to_inputs(
86
147
  inputs["armor_magic_bonus"] = armor_magic
87
148
  inputs["has_shield"] = has_shield
88
149
 
150
+ # --- Species natural armor ---
151
+ inputs["natural_armor_ac"] = _parse_natural_armor(species_data)
152
+
89
153
  # --- Spellcasting ---
90
154
  spellcasting_type = class_data.get("properties", {}).get("spellcasting_type") or class_data.get(
91
155
  "spellcasting_type", "none"
@@ -227,7 +291,8 @@ def computed_values_to_sheet(
227
291
  speed_val = speed
228
292
 
229
293
  # --- Hit dice ---
230
- hit_die = class_data.get("hit_die", "d8")
294
+ hit_die_size = _resolve_hit_die(class_data)
295
+ hit_die = f"d{hit_die_size}"
231
296
  hit_dice_str = computed.get("hit_dice", f"{level}{hit_die}")
232
297
 
233
298
  # --- Spellcasting ---
@@ -153,6 +153,7 @@ def assemble_character_inputs(
153
153
  inputs["armor_type"] = "none"
154
154
  inputs["armor_magic_bonus"] = 0
155
155
  inputs["has_shield"] = has_shield
156
+ inputs["natural_armor_ac"] = 0
156
157
 
157
158
  # ------------------------------------------------------------------
158
159
  # 8. Saving throw proficiencies
@@ -182,6 +182,20 @@ def _build_nodes() -> dict[str, ComputationNode]:
182
182
  )
183
183
  )
184
184
 
185
+ add(
186
+ ComputationNode(
187
+ id="natural_armor_ac",
188
+ node_type=NodeType.INPUT,
189
+ label="Natural Armor AC",
190
+ layer=0,
191
+ group="equipment",
192
+ default_value=0,
193
+ min_value=0,
194
+ max_value=30,
195
+ description="Base AC from species natural armor (0 = none).",
196
+ )
197
+ )
198
+
185
199
  add(
186
200
  ComputationNode(
187
201
  id="speed_base",
@@ -446,9 +460,9 @@ def _build_nodes() -> dict[str, ComputationNode]:
446
460
  group="combat",
447
461
  formula=FormulaSpec(
448
462
  op="ac_with_armor",
449
- args=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"],
463
+ args=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield", "natural_armor_ac"],
450
464
  ),
451
- inputs=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"],
465
+ inputs=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield", "natural_armor_ac"],
452
466
  min_value=1,
453
467
  description="Base AC from armor + DEX (capped) + magic + shield. PHB p.14.",
454
468
  )