diff --git a/pyproject.toml b/pyproject.toml index 17d79e9..8f9d1d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/dndwright/__init__.py b/src/dndwright/__init__.py index 51f197c..a6e437b 100644 --- a/src/dndwright/__init__.py +++ b/src/dndwright/__init__.py @@ -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 ( diff --git a/src/dndwright/rules/adapters.py b/src/dndwright/rules/adapters.py index b7f5477..5ee5d79 100644 --- a/src/dndwright/rules/adapters.py +++ b/src/dndwright/rules/adapters.py @@ -21,7 +21,6 @@ import re -import re def _resolve_hit_die(class_data: dict) -> int: diff --git a/src/dndwright/rules/character_evaluator.py b/src/dndwright/rules/character_evaluator.py index 80dad5b..89722a4 100644 --- a/src/dndwright/rules/character_evaluator.py +++ b/src/dndwright/rules/character_evaluator.py @@ -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 @@ -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. @@ -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) diff --git a/src/dndwright/rules/homebrew_validator.py b/src/dndwright/rules/homebrew_validator.py index 44cc07a..cf0d2e7 100644 --- a/src/dndwright/rules/homebrew_validator.py +++ b/src/dndwright/rules/homebrew_validator.py @@ -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 diff --git a/tests/test_api_contract.py b/tests/test_api_contract.py index a0c9665..a7020d1 100644 --- a/tests/test_api_contract.py +++ b/tests/test_api_contract.py @@ -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) " diff --git a/tests/test_evaluate_character_components.py b/tests/test_evaluate_character_components.py new file mode 100644 index 0000000..6e91bcd --- /dev/null +++ b/tests/test_evaluate_character_components.py @@ -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. diff --git a/tests/test_homebrew_validator.py b/tests/test_homebrew_validator.py index f4416b5..4d3d478 100644 --- a/tests/test_homebrew_validator.py +++ b/tests/test_homebrew_validator.py @@ -1,5 +1,4 @@ """Tests for homebrew validators.""" -import pytest from dndwright.rules.homebrew_validator import ( validate_class_homebrew, validate_species_homebrew,