diff --git a/CHANGELOG.md b/CHANGELOG.md index a669394..89bca2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 0.25.1 + +### Fixed +- 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). +- Species natural armor now contributes to AC via a `natural_armor_ac` computation-graph input; AC = max(natural, equipped + dex + magic + shield) per 5e. + + All notable changes to dndwright are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and the project aims to follow [Semantic Versioning](https://semver.org/). diff --git a/pyproject.toml b/pyproject.toml index f8ed070..5849252 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "dndwright" -version = "0.25.0" +version = "0.25.1" 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)." readme = "README.md" requires-python = ">=3.10" diff --git a/src/dndwright/rules/adapters.py b/src/dndwright/rules/adapters.py index 7f20f5c..b7f5477 100644 --- a/src/dndwright/rules/adapters.py +++ b/src/dndwright/rules/adapters.py @@ -18,6 +18,67 @@ SPELLCASTING_TYPE_BY_CLASS, ) +import re + + +import re + + +def _resolve_hit_die(class_data: dict) -> int: + """Return the resolved hit die size, applying lookup-table overrides. + + Mirrors the logic in ``character_data_to_inputs`` lines 51-58 so the display + value matches the computation. + """ + hit_die_str = class_data.get("hit_die", "d8") + hit_die_size = int(hit_die_str.replace("d", "")) if isinstance(hit_die_str, str) else 8 + + class_name = class_data.get("class_name", "").lower() + archetype = ( + class_data.get("archetype") + or class_data.get("properties", {}).get("archetype", "") + ) + if class_name in HIT_DIE_BY_CLASS: + hit_die_size = HIT_DIE_BY_CLASS[class_name] + elif archetype in HIT_DIE_BY_ARCHETYPE: + hit_die_size = HIT_DIE_BY_ARCHETYPE[archetype] + + return hit_die_size + + +_NATURAL_ARMOR_KEYWORDS = re.compile( + r"(carapace|natural armor|chitin|exoskeleton|scales|tough hide|bark skin|" + r"thick fur|bony plates|armored shell)", + re.IGNORECASE, +) +_AC_VALUE_RE = re.compile(r"\bAC\b.*?(\d+)", re.IGNORECASE) + + +def _parse_natural_armor(species_data: dict) -> int: + """Extract natural armor AC from species traits, or 0 if none.""" + # Explicit field (future-proof) + explicit = species_data.get("natural_armor_ac") + if isinstance(explicit, int) and explicit > 0: + return explicit + + traits: list[dict] = species_data.get("traits", []) or [] + if not isinstance(traits, list): + return 0 + + for trait in traits: + if not isinstance(trait, dict): + continue + name = (trait.get("name") or "").lower() + desc = (trait.get("description") or "").lower() + combined = name + " " + desc + + if _NATURAL_ARMOR_KEYWORDS.search(combined): + match = _AC_VALUE_RE.search(combined) + if match: + return int(match.group(1)) + + return 0 + def character_data_to_inputs( ability_scores: dict[str, int], @@ -86,6 +147,9 @@ def character_data_to_inputs( inputs["armor_magic_bonus"] = armor_magic inputs["has_shield"] = has_shield + # --- Species natural armor --- + inputs["natural_armor_ac"] = _parse_natural_armor(species_data) + # --- Spellcasting --- spellcasting_type = class_data.get("properties", {}).get("spellcasting_type") or class_data.get( "spellcasting_type", "none" @@ -227,7 +291,8 @@ def _fmt(val: int | None) -> str: speed_val = speed # --- Hit dice --- - hit_die = class_data.get("hit_die", "d8") + hit_die_size = _resolve_hit_die(class_data) + hit_die = f"d{hit_die_size}" hit_dice_str = computed.get("hit_dice", f"{level}{hit_die}") # --- Spellcasting --- diff --git a/src/dndwright/rules/assembler.py b/src/dndwright/rules/assembler.py index d8b50b3..3fee4ae 100644 --- a/src/dndwright/rules/assembler.py +++ b/src/dndwright/rules/assembler.py @@ -153,6 +153,7 @@ def assemble_character_inputs( inputs["armor_type"] = "none" inputs["armor_magic_bonus"] = 0 inputs["has_shield"] = has_shield + inputs["natural_armor_ac"] = 0 # ------------------------------------------------------------------ # 8. Saving throw proficiencies diff --git a/src/dndwright/rules/dnd_5e_2024.py b/src/dndwright/rules/dnd_5e_2024.py index f991227..1168054 100644 --- a/src/dndwright/rules/dnd_5e_2024.py +++ b/src/dndwright/rules/dnd_5e_2024.py @@ -182,6 +182,20 @@ def add(node: ComputationNode) -> None: ) ) + add( + ComputationNode( + id="natural_armor_ac", + node_type=NodeType.INPUT, + label="Natural Armor AC", + layer=0, + group="equipment", + default_value=0, + min_value=0, + max_value=30, + description="Base AC from species natural armor (0 = none).", + ) + ) + add( ComputationNode( id="speed_base", @@ -446,9 +460,9 @@ def add(node: ComputationNode) -> None: group="combat", formula=FormulaSpec( op="ac_with_armor", - args=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"], + args=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield", "natural_armor_ac"], ), - inputs=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield"], + inputs=["armor_type", "dexterity_mod", "armor_magic_bonus", "has_shield", "natural_armor_ac"], min_value=1, description="Base AC from armor + DEX (capped) + magic + shield. PHB p.14.", ) diff --git a/src/dndwright/rules/operations.py b/src/dndwright/rules/operations.py index 7d8e7f1..c21b02c 100644 --- a/src/dndwright/rules/operations.py +++ b/src/dndwright/rules/operations.py @@ -172,15 +172,17 @@ def op_multiclass_hp(args: list[Any], _tables: dict) -> int: def op_ac_with_armor(args: list[Any], tables: dict) -> int: - """Compute AC from armor type + dex mod. + """Compute AC from armor type + dex mod + natural armor. - args: [armor_type, dex_mod, magic_bonus, has_shield] + args: [armor_type, dex_mod, magic_bonus, has_shield, natural_armor_ac] Uses lookup tables: armor_base_ac, armor_max_dex + AC = max(natural_armor_ac, armor_base_ac + dex(capped) + magic + shield) per 5e. """ armor_type = str(args[0]).lower().replace(" ", "_") if args[0] else "none" dex_mod = int(args[1]) magic_bonus = int(args[2]) if len(args) > 2 else 0 has_shield = bool(args[3]) if len(args) > 3 else False + natural_armor_ac = int(args[4]) if len(args) > 4 else 0 armor_base_ac = tables.get("armor_base_ac", {}) armor_max_dex = tables.get("armor_max_dex", {}) @@ -199,7 +201,7 @@ def op_ac_with_armor(args: list[Any], tables: dict) -> int: if has_shield: ac += 2 - return ac + return max(natural_armor_ac, ac) def op_spell_slots(args: list[Any], tables: dict) -> dict[str, int]: diff --git a/tests/test_adapters_bugs.py b/tests/test_adapters_bugs.py new file mode 100644 index 0000000..1d30e4f --- /dev/null +++ b/tests/test_adapters_bugs.py @@ -0,0 +1,221 @@ +"""Tests for adapters bug fixes (hit-die display + natural armor AC).""" +from dndwright import character_data_to_inputs, computed_values_to_sheet + + +class TestHitDieDisplayFix: + """Bug 1: hit-die display must match computation after archetype overrides.""" + + def test_hit_die_uses_archetype_override_in_display(self): + """Sheet hit_die_type must show d12 when archetype=primal_martial overrides d10.""" + class_data = { + "class_name": "barbarian", + "hit_die": "d10", + "archetype": "primal_martial", + } + species_data = {"speed": 30} + ability_scores = { + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + inputs = character_data_to_inputs( + ability_scores, class_data, None, species_data, None, 3, + ) + # Computation uses d12 + assert inputs["class_hit_dice"].get("barbarian") == 12 + + # Display must also reflect d12 + sheet = computed_values_to_sheet( + {}, ability_scores, class_data, None, species_data, None, 3, + ) + assert sheet["hit_die_type"] == "d12" + + def test_hit_die_uses_class_lookup_in_display(self): + """Sheet hit_die_type must match class lookup for known SRD class.""" + class_data = { + "class_name": "wizard", + # No hit_die field at all — should fall back to d6 from lookup + } + species_data = {"speed": 30} + ability_scores = { + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + inputs = character_data_to_inputs( + ability_scores, class_data, None, species_data, None, 1, + ) + assert inputs["class_hit_dice"].get("wizard") == 6 + + sheet = computed_values_to_sheet( + {}, ability_scores, class_data, None, species_data, None, 1, + ) + assert sheet["hit_die_type"] == "d6" + + def test_hit_die_no_override_uses_llm_value(self): + """When no lookup matches, display the LLM's raw hit_die value.""" + class_data = { + "class_name": "custom_homebrew_class", + "hit_die": "d10", + } + species_data = {"speed": 30} + ability_scores = { + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + sheet = computed_values_to_sheet( + {}, ability_scores, class_data, None, species_data, None, 1, + ) + assert sheet["hit_die_type"] == "d10" + + def test_hit_die_compute_and_display_match(self): + """Primary regression test: computation and display must agree.""" + class_data = { + "class_name": "fighter", + "hit_die": "d6", # LLM claims d6, but fighter lookup says d10 + } + species_data = {"speed": 30} + ability_scores = { + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + inputs = character_data_to_inputs( + ability_scores, class_data, None, species_data, None, 1, + ) + # Computation uses d10 (fighter lookup overrides d6) + assert inputs["class_hit_dice"].get("fighter") == 10 + + sheet = computed_values_to_sheet( + {}, ability_scores, class_data, None, species_data, None, 1, + ) + # Display must also show d10, not d6 + assert sheet["hit_die_type"] == "d10" + + +class TestNaturalArmorACFix: + """Bug 2: species natural armor must contribute to AC computation.""" + + @staticmethod + def _sheet_for_species(species_data): + ability_scores = { + "strength": 10, "dexterity": 14, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + class_data = {"class_name": "wizard", "hit_die": "d6"} + inputs = character_data_to_inputs( + ability_scores, class_data, None, species_data, None, 1, + ) + return inputs + + def test_carapace_trait_adds_natural_armor_input(self): + """Species with 'Carapace' trait → natural_armor_ac set in inputs.""" + species = { + "speed": 30, + "traits": [ + { + "name": "Carapace", + "description": "Your chitinous shell gives you natural armor. Your base AC is 12.", + } + ], + } + inputs = self._sheet_for_species(species) + assert inputs["natural_armor_ac"] == 12 + + def test_no_natural_armor_trait_sets_zero(self): + """Species without natural armor → natural_armor_ac = 0.""" + species = { + "speed": 30, + "traits": [ + {"name": "Darkvision", "description": "You can see in the dark 60 ft."}, + ], + } + inputs = self._sheet_for_species(species) + assert inputs["natural_armor_ac"] == 0 + + def test_explicit_natural_armor_ac_field(self): + """Explicit natural_armor_ac field on species data is used.""" + species = { + "speed": 30, + "natural_armor_ac": 15, + } + inputs = self._sheet_for_species(species) + assert inputs["natural_armor_ac"] == 15 + + def test_natural_armor_contributes_to_computed_ac(self): + """Compute full sheet — AC reflects natural armor + DEX when no equipment. + + Unarmored AC = 10 + DEX(2) = 12. Natural armor AC = 13. + max(10 + 2, 13) = 13. + """ + from dndwright import DND_5E_2024_RULESET, evaluate + + species = { + "speed": 30, + "traits": [ + { + "name": "Tough Hide", + "description": "Your thick, armored hide gives you natural armor. Your AC is 13.", + } + ], + } + ability_scores = { + "strength": 10, "dexterity": 14, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + class_data = {"class_name": "wizard", "hit_die": "d6"} + + inputs = character_data_to_inputs( + ability_scores, class_data, None, species, None, 1, + ) + computed = evaluate(DND_5E_2024_RULESET, inputs) + assert computed["armor_class"] == 13 + assert inputs["natural_armor_ac"] == 13 + + def test_natural_armor_lower_than_equipped_armor(self): + """When equipped armor gives better AC, natural armor is ignored.""" + from dndwright import DND_5E_2024_RULESET, evaluate + + species = { + "speed": 30, + "traits": [ + { + "name": "Carapace", + "description": "Your shell gives natural armor AC 12.", + } + ], + } + ability_scores = { + "strength": 10, "dexterity": 10, "constitution": 10, + "intelligence": 10, "wisdom": 10, "charisma": 10, + } + class_data = {"class_name": "fighter", "hit_die": "d10"} + equipment = { + "armor": { + "type": "chain_mail", + "magic_bonus": 0, + }, + } + inputs = character_data_to_inputs( + ability_scores, class_data, None, species, None, 1, equipment, + ) + computed = evaluate(DND_5E_2024_RULESET, inputs) + # Chain mail base AC = 16, no DEX. max(12, 16) = 16. + assert computed["armor_class"] == 16 + + def test_exoskeleton_trait_recognized(self): + """'Exoskeleton' keyword also triggers natural armor parsing.""" + species = { + "speed": 30, + "traits": [ + { + "name": "Chitin Exoskeleton", + "description": "Your exoskeleton provides natural protection. AC 14.", + } + ], + } + inputs = self._sheet_for_species(species) + assert inputs["natural_armor_ac"] == 14 + + def test_no_traits_handled_gracefully(self): + """Missing traits, non-list traits, empty traits → natural_armor_ac = 0.""" + for species in [{}, {"traits": None}, {"traits": []}, {"traits": "not_a_list"}]: + inputs = self._sheet_for_species(species) + assert inputs["natural_armor_ac"] == 0