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
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.2"
version = "0.26.0"
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
2 changes: 1 addition & 1 deletion src/dndwright/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@
validate_ruleset,
)

__version__ = "0.25.0"
__version__ = "0.26.0"

# homebrew validation (structural rules checks on LLM-generated components)
from .rules.homebrew_validator import (
Expand Down
1 change: 0 additions & 1 deletion src/dndwright/rules/adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import re


import re


def _resolve_hit_die(class_data: dict) -> int:
Expand Down
13 changes: 11 additions & 2 deletions src/dndwright/rules/character_evaluator.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

from .adapters import character_data_to_inputs, computed_values_to_sheet
from .assembler import apply_modifiers
from .compose import Component, compose, component_from_dict
from .dnd_5e_2024 import DND_5E_2024_RULESET
from .evaluator import evaluate
from .theme_scaling import ThemeScalingLayer, apply_theme_scaling
Expand Down Expand Up @@ -178,6 +179,7 @@ def evaluate_character(
*,
strict: bool = False,
scaling: ThemeScalingLayer | None = None,
components: list | None = None,
) -> dict:
"""Evaluate a character from session data → full computed character sheet.

Expand Down Expand Up @@ -218,8 +220,15 @@ def evaluate_character(
equipment=fields["equipment"],
)

# Evaluate the computation graph (theme-scaled when a scaling layer is supplied)
computed = evaluate(_ruleset_for(scaling), inputs)
# Compose any adopted item/equipment components onto the ruleset before evaluating —
# e.g. an allocated "+1 armor_class" or "resistance to fire" item snaps its modifier onto
# the computed sheet (each `component` is a Component, or a component_from_dict-shaped spec
# dict as persisted on a GraphComponent). Then evaluate the (theme-scaled) computation graph.
ruleset = _ruleset_for(scaling)
if components:
comps = [c if isinstance(c, Component) else component_from_dict(c) for c in components]
ruleset = compose(ruleset, *comps)
computed = evaluate(ruleset, inputs)

# Apply NodeModifiers from feats, class features, etc.
computed = apply_modifiers(computed, inputs)
Expand Down
2 changes: 1 addition & 1 deletion src/dndwright/rules/homebrew_validator.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ def validate_species_homebrew(species_data: dict[str, Any]) -> list[str]:
continue
name = trait.get("name", "?")
if not isinstance(name, str) or not name.strip():
problems.append(f"Species trait missing name")
problems.append("Species trait missing name")

return problems

Expand Down
3 changes: 2 additions & 1 deletion tests/test_api_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ def test_evaluate_takes_ruleset_and_inputs(self):
"evaluate": "(ruleset: 'Ruleset', input_values: 'dict[str, Any]') -> 'dict[str, Any]'",
"evaluate_character": (
"(session_data: 'dict', *, strict: 'bool' = False, "
"scaling: 'ThemeScalingLayer | None' = None) -> 'dict'"
"scaling: 'ThemeScalingLayer | None' = None, "
"components: 'list | None' = None) -> 'dict'"
),
"generate_library": (
"(llm: 'JsonLLM', classes: 'int' = 6, species: 'int' = 6, creatures: 'int' = 12) "
Expand Down
69 changes: 69 additions & 0 deletions tests/test_evaluate_character_components.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""evaluate_character(..., components=[...]) — compose allocated item modifiers onto the sheet.

Covers the v0.26 `components` kwarg: an allocated item's dndwright Component (or a
component_from_dict-shaped spec dict, as persisted on a storyflow GraphComponent) is composed
onto the ruleset before evaluation, so its modifier lands on the computed sheet. `None`/[]
is a pure no-op (default behaviour unchanged).
"""

from dndwright.rules.character_evaluator import evaluate_character
from dndwright.rules.compose import (
Component,
component_from_content,
component_to_dict,
modifier,
)

# Wizard, DEX 14 (+2) → unarmored AC 12; deterministic base.
CHAR = {
"ability_scores": {"strength": 8, "dexterity": 14, "constitution": 14,
"intelligence": 18, "wisdom": 12, "charisma": 10},
"class_data": {"class_name": "wizard"},
"species_data": {"name": "Human", "speed": 30},
"level": 5,
}


def _plus1_ac() -> Component:
return component_from_content({"component": [{"target": "armor_class", "mode": "add", "amount": 1}]})


def test_no_components_is_a_noop():
assert evaluate_character(CHAR) == evaluate_character(CHAR, components=None)
assert evaluate_character(CHAR) == evaluate_character(CHAR, components=[])


def test_plus1_ac_component_object_composes():
base = evaluate_character(CHAR)["armor_class"]
composed = evaluate_character(CHAR, components=[_plus1_ac()])["armor_class"]
assert composed == base + 1, f"expected +1 AC ({base + 1}), got {composed}"


def test_plus1_ac_serialized_dict_composes():
"""A component_to_dict-round-tripped spec (as stored on a GraphComponent) composes too."""
base = evaluate_character(CHAR)["armor_class"]
spec = component_to_dict(_plus1_ac())
composed = evaluate_character(CHAR, components=[spec])["armor_class"]
assert composed == base + 1, f"expected +1 AC ({base + 1}) from serialized spec, got {composed}"


def test_two_unique_ac_items_stack():
"""Two DISTINCT-id AC items stack (+1 +1 = +2). Item components MUST carry unique ids —
dndwright namespaces each component's nodes by ``component.id``, so two components sharing
an id (e.g. the ``component_from_content`` default ``"item"``) collide and cross-contaminate.
storyflow uses each owned GraphComponent's unique node id, so real allocations are safe."""
base = evaluate_character(CHAR)["armor_class"]
comps = [
modifier("shield_of_vantor", target="armor_class", amount=1),
modifier("ring_of_protection", target="armor_class", amount=1),
]
composed = evaluate_character(CHAR, components=comps)["armor_class"]
assert composed == base + 2, f"two +1-AC items should stack to +2 ({base + 2}), got {composed}"


# SCOPE NOTE (inc-4 v1): item components compose onto the computed graph, but only targets the
# character-sheet reshape reads from computed NODES surface on the sheet — confirmed: `armor_class`
# and `initiative` do; `hp_max`/`speed`/ability-scores/`resistances` compose the node but the
# reshape reads those from input/other keys, so they don't yet surface. inc-4 targets AC (the
# "+1 shield → +1 AC" headline). Ability-score-setting + resistance items (sheet-reshape to read
# composed values) are a tracked dndwright follow-up in the items workstream.
1 change: 0 additions & 1 deletion tests/test_homebrew_validator.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
"""Tests for homebrew validators."""
import pytest
from dndwright.rules.homebrew_validator import (
validate_class_homebrew,
validate_species_homebrew,
Expand Down