Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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/).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
67 changes: 66 additions & 1 deletion src/dndwright/rules/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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 ---
Expand Down
1 change: 1 addition & 0 deletions src/dndwright/rules/assembler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 16 additions & 2 deletions src/dndwright/rules/dnd_5e_2024.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
)
Expand Down
8 changes: 5 additions & 3 deletions src/dndwright/rules/operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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", {})
Expand All @@ -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]:
Expand Down
Loading
Loading