From 6fe17560a33cadc865d13e5262c0fd64dce64ab2 Mon Sep 17 00:00:00 2001 From: Anthony Sligar Date: Sun, 28 Jun 2026 06:10:05 -0400 Subject: [PATCH] feat(combat): inject optional allowed-set into clean_damage_types/combatant_defenses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a keyword-only allowed: frozenset[str] = DAMAGE_TYPES to clean_damage_types and combatant_defenses. Default preserves SRD-13 behavior (backward-compatible); a caller governing its own damage-type vocabulary passes SRD-13 ∪ custom so registered types survive the filter at assembly, computed-stats read, AND live combat damage application — no split-brain. dndwright stays domain-agnostic. Bump 0.25.2 -> 0.26.0. Public API (__all__) unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JXPSRe6rg8jtdAAvqZrxvf --- CHANGELOG.md | 15 ++++++- src/dndwright/combat/combat.py | 33 +++++++++++---- tests/test_defenses_and_serialization.py | 53 ++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 488b71e..3bc15dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Changelog +## [Unreleased] + +### Added +- `clean_damage_types()` and `combatant_defenses()` now accept an optional keyword-only + `allowed: frozenset[str] = DAMAGE_TYPES`. By default they intersect with the SRD-13 + `DAMAGE_TYPES` exactly as before (fully backward-compatible); a caller that governs its own + damage-type vocabulary can pass a wider set (e.g. `SRD-13 ∪ custom`) so caller-registered + types survive the filter — at component assembly, the computed-stats read, **and** live combat + damage application — instead of being silently dropped. dndwright stays domain-agnostic: it does + not know where `allowed` comes from; members must be lower-cased (the caller owns canonicalisation). + Enables host applications to support per-campaign/per-universe custom damage types without a + split-brain between the character sheet and combat. Public API names (`__all__`) unchanged. + ## 0.25.2 ### Fixed @@ -24,8 +37,6 @@ All notable changes to dndwright are documented here. The format follows `tests/test_api_contract.py`). While the version is `0.x`, minor versions may make breaking changes; these will always be noted here. -## [Unreleased] - ## [0.25.0] — 2026-06-13 ### Added diff --git a/src/dndwright/combat/combat.py b/src/dndwright/combat/combat.py index da40943..9b000ec 100644 --- a/src/dndwright/combat/combat.py +++ b/src/dndwright/combat/combat.py @@ -69,18 +69,29 @@ def damage_multiplier( return mult -def clean_damage_types(values: Any) -> tuple[str, ...]: +def clean_damage_types( + values: Any, *, allowed: frozenset[str] = DAMAGE_TYPES +) -> tuple[str, ...]: """Normalise a collection of damage-type strings: lower-cased, de-duplicated, sorted, and - intersected with :data:`DAMAGE_TYPES` so narrative junk (``"Fire Resistance"``, prose like + intersected with ``allowed`` so narrative junk (``"Fire Resistance"``, prose like ``"nonmagical piercing"``) is dropped. The single source of truth for damage-type cleaning — used by :func:`combatant_defenses` and by callers that build resistance contributions. + + ``allowed`` defaults to the SRD-13 :data:`DAMAGE_TYPES` (so existing callers are unchanged), + but may be widened by a caller that governs its own type vocabulary — e.g. an application that + lets a campaign register custom damage types passes ``SRD-13 ∪ custom`` so those types survive + the filter instead of being silently dropped. dndwright stays domain-agnostic: it does not know + where ``allowed`` comes from. **Members of ``allowed`` must already be lower-cased**, matching + how :data:`DAMAGE_TYPES` is defined (the caller owns canonicalisation). """ if not values: return () - return tuple(sorted({str(v).lower() for v in values} & DAMAGE_TYPES)) + return tuple(sorted({str(v).lower() for v in values} & allowed)) -def combatant_defenses(computed: Mapping[str, Any]) -> dict[str, frozenset[str]]: +def combatant_defenses( + computed: Mapping[str, Any], *, allowed: frozenset[str] = DAMAGE_TYPES +) -> dict[str, frozenset[str]]: """Pull the damage-defence channels out of an evaluated character graph (or a plain ``{"resistances": [...], "immunities": [...], "vulnerabilities": [...]}`` snapshot). @@ -92,13 +103,17 @@ def combatant_defenses(computed: Mapping[str, Any]) -> dict[str, frozenset[str]] computed = evaluate(compose(DND_5E_2024_RULESET, *components), inputs) state = CombatantState(current_hp=hp, max_hp=hp, **combatant_defenses(computed)) - Members are cleaned via :func:`clean_damage_types` (lower-cased, intersected with - :data:`DAMAGE_TYPES`), so junk can't slip in and silently break multiplier matching. + Members are cleaned via :func:`clean_damage_types` (lower-cased, intersected with ``allowed``), + so junk can't slip in and silently break multiplier matching. ``allowed`` defaults to the + SRD-13 :data:`DAMAGE_TYPES`; pass a wider set (``SRD-13 ∪ custom``) to keep caller-registered + types — it is threaded unchanged to :func:`clean_damage_types` for every channel. """ return { - "resistances": frozenset(clean_damage_types(computed.get("resistances"))), - "immunities": frozenset(clean_damage_types(computed.get("immunities"))), - "vulnerabilities": frozenset(clean_damage_types(computed.get("vulnerabilities"))), + "resistances": frozenset(clean_damage_types(computed.get("resistances"), allowed=allowed)), + "immunities": frozenset(clean_damage_types(computed.get("immunities"), allowed=allowed)), + "vulnerabilities": frozenset( + clean_damage_types(computed.get("vulnerabilities"), allowed=allowed) + ), } diff --git a/tests/test_defenses_and_serialization.py b/tests/test_defenses_and_serialization.py index 4305279..dca1701 100644 --- a/tests/test_defenses_and_serialization.py +++ b/tests/test_defenses_and_serialization.py @@ -119,3 +119,56 @@ def test_clean_damage_types_normalises_and_filters(): from dndwright.combat import combatant_defenses d = combatant_defenses({"resistances": ["Fire", "junk"], "immunities": ["poison"]}) assert d["resistances"] == frozenset({"fire"}) and d["immunities"] == frozenset({"poison"}) + + +# --- allowed-set injection (caller-governed type vocabulary) ---------------- + +def test_clean_damage_types_allowed_widens_vocabulary(): + from dndwright.combat import DAMAGE_TYPES, clean_damage_types + # default = SRD-13: a custom type is dropped (the silent-drop the registry replaces) + assert clean_damage_types(["fire", "radiation"]) == ("fire",) + # widened allowed = SRD-13 ∪ {radiation}: the custom type now survives + allowed = DAMAGE_TYPES | {"radiation"} + assert clean_damage_types(["fire", "radiation"], allowed=allowed) == ("fire", "radiation") + # still normalises (lower-case/dedupe/sort) and still drops genuine junk + assert clean_damage_types(["Radiation", "RADIATION", "junk"], allowed=allowed) == ("radiation",) + + +def test_clean_damage_types_default_unchanged_backcompat(): + from dndwright.combat import DAMAGE_TYPES, clean_damage_types + # no allowed kwarg → identical to intersecting with DAMAGE_TYPES (existing callers unaffected) + assert clean_damage_types(["fire", "radiation"]) == clean_damage_types( + ["fire", "radiation"], allowed=DAMAGE_TYPES + ) + + +def test_combatant_defenses_threads_allowed_per_channel(): + from dndwright.combat import DAMAGE_TYPES, combatant_defenses + allowed = DAMAGE_TYPES | {"radiation"} + d = combatant_defenses( + {"resistances": ["radiation", "fire"], "immunities": ["radiation"]}, allowed=allowed + ) + assert d["resistances"] == frozenset({"radiation", "fire"}) + assert d["immunities"] == frozenset({"radiation"}) + # without allowed, the custom type is dropped on every channel (default SRD-13) + d0 = combatant_defenses({"resistances": ["radiation", "fire"], "immunities": ["radiation"]}) + assert d0["resistances"] == frozenset({"fire"}) and d0["immunities"] == frozenset() + + +def test_registered_type_governs_actual_combat_damage(): + """The real-path proof: a caller-registered type must HALVE damage in apply_damage, + not just survive the filter — i.e. it is governed at the point damage is applied.""" + from dndwright.combat import DAMAGE_TYPES, combatant_defenses + allowed = DAMAGE_TYPES | {"radiation"} + composed = compose( + R, modifier("r", target="resistances", amount=["radiation"], mode="union") + ) + sheet = evaluate(composed, _inputs()) + # with the widened allowed set, the registered resistance reaches the combat state + state = CombatantState(current_hp=20, max_hp=20, **combatant_defenses(sheet, allowed=allowed)) + resisted, applied = apply_damage(state, 10, damage_type="radiation") + assert resisted.current_hp == 15 and applied.multiplier == 0.5 + # default (SRD-13) drops the resistance → full damage = the split-brain we are preventing + state0 = CombatantState(current_hp=20, max_hp=20, **combatant_defenses(sheet)) + full, applied0 = apply_damage(state0, 10, damage_type="radiation") + assert full.current_hp == 10 and applied0.multiplier == 1.0