From 365f3a92e90eefe18885a0fc5885dbb3e0ac0d63 Mon Sep 17 00:00:00 2001 From: boss Date: Wed, 1 Jul 2026 01:54:35 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(rules):=20evaluate=5Fcharacter(compone?= =?UTF-8?q?nts=3D...)=20=E2=80=94=20compose=20allocated=20item=20modifiers?= =?UTF-8?q?=20onto=20the=20sheet?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 'components' kwarg to evaluate_character: item/equipment dndwright Components (or component_from_dict-shaped spec dicts, as persisted on a storyflow GraphComponent) are compose()'d onto the ruleset before evaluate, so an allocated item's modifier lands on the computed sheet (e.g. a +1 armor_class item -> +1 AC). None/[] is a pure no-op. Enables the storyflow items/equipment workstream (inc-4: an equipped item composes onto the character sheet). compose had no shipping callers before; this is its first real use. Notes: item components MUST carry unique ids (dndwright namespaces by component.id; the component_from_content default 'item' collides across items). Only targets the sheet-reshape reads from computed nodes surface (armor_class, initiative confirmed); ability-score/resistance targets compose the node but need a sheet-reshape follow-up. Tests: +1 AC single + two-unique stack + serialized-dict + no-op; api-contract signature updated (additive kwarg). --- src/dndwright/rules/character_evaluator.py | 13 +++- tests/test_api_contract.py | 3 +- tests/test_evaluate_character_components.py | 69 +++++++++++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) create mode 100644 tests/test_evaluate_character_components.py 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/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. From 1ae8bcdf4fc8447a3714d9b61f4fced7ed5bd03c Mon Sep 17 00:00:00 2001 From: Anthony Sligar Date: Wed, 1 Jul 2026 07:29:36 -0400 Subject: [PATCH 2/3] chore: fix ruff lint + bump version 0.25.2->0.26.0 for release (inc-4 evaluate_character components compose) Greens the Lint(ruff) CI step (3 pre-existing auto-fixable errors: unused import, extraneous f-prefix) and bumps the version for the PyPI release that unblocks storyflow inc-4 (evaluate_character(components=...)). Compose tests 4/4 + full suite 1655 passed (worktree source). Co-Authored-By: Claude Opus 4.8 (1M context) --- pyproject.toml | 2 +- src/dndwright/rules/adapters.py | 1 - src/dndwright/rules/homebrew_validator.py | 2 +- tests/test_homebrew_validator.py | 1 - 4 files changed, 2 insertions(+), 4 deletions(-) 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/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/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_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, From d31521ac0be935242ac997edd9fb89a16605c032 Mon Sep 17 00:00:00 2001 From: Anthony Sligar Date: Wed, 1 Jul 2026 07:32:32 -0400 Subject: [PATCH 3/3] fix: sync __version__ to 0.26.0 (was stale 0.25.0; test_version_matches_package_metadata) Co-Authored-By: Claude Opus 4.8 (1M context) --- src/dndwright/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 (