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
15 changes: 13 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down
33 changes: 24 additions & 9 deletions src/dndwright/combat/combat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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)
),
}


Expand Down
53 changes: 53 additions & 0 deletions tests/test_defenses_and_serialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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