fix(rules): apply energy resistance per damage component

This commit is contained in:
2026-09-11 11:24:11 +02:00
parent 1960b8f655
commit 9fb1c2d75a
3 changed files with 284 additions and 27 deletions
+64 -22
View File
@@ -2256,9 +2256,8 @@ class CombatEngine:
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
damage = (
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
)
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
damage = sum(rolls) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
# bonus increased by half) with a two-handed weapon, a one-handed
@@ -2272,9 +2271,9 @@ class CombatEngine:
damage *= weapon.crit_mult
damage += sneak_total
damage = self._apply_dr(damage, weapon, target)
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
damage = self._apply_energy_resistance(damage, weapon_types, target)
return crit, False, damage
return crit, False, self._apply_energy_resistance_per_component(
damage, weapon, rolls, crit=crit, target=target
)
def _concealment_chance(self, target: CombatantState) -> int:
"""Highest miss chance (%) among the target's active concealment conditions."""
@@ -2439,30 +2438,73 @@ class CombatEngine:
)
return False
def _apply_energy_resistance_per_component(
self,
damage: int,
weapon: AttackSpec,
rolls: list[int],
*,
crit: bool,
target: CombatantState,
) -> int:
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
PF1e (CRB "Energy Resistance"/"Immunity to Energy Damage"): each
damage type's resistance reduces only the dice of that type — a
weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
loses only its fire portion; the slashing dice, the flat bonuses
(damage_bonus, ability/damage modifiers, Power Attack) and the
untyped sneak precision dice are never eaten by energy resistance.
Each component's share of the final damage is its own roll times
the crit multiplier (engine convention: every damage die is
multiplied on a crit) and its reduction is capped at that share.
A component carrying several energy types uses the largest matching
resistance (documented simplification: one die roll cannot be split
across the listed types). Physical-only components match nothing in
the target's resistance/immunity maps and stay on the DR path, which
was applied to the lumped total just before this step (unchanged);
single-component weapons keep the exact arithmetic of the old
lumped-total path.
"""
mult = weapon.crit_mult if crit else 1
for comp, roll in zip(weapon.damage, rolls, strict=True):
damage -= self._energy_reduction(roll * mult, frozenset(comp.types), target)
return max(0, damage)
def _energy_reduction(
self, damage: int, damage_types: frozenset[str], target: CombatantState
) -> int:
"""Reduction the target's immunity/resistance applies to one
damage portion of the given types, capped at the portion itself."""
immunities = target.combatant.immunities
if immunities and damage_types & frozenset(immunities):
return damage
resistances = target.combatant.resistances
if not resistances:
return 0
max_resist = max(
(resistances[t] for t in damage_types if t in resistances),
default=0,
)
return min(damage, max_resist)
def _apply_energy_resistance(
self,
damage: int,
damage_types: frozenset[str],
target: CombatantState,
) -> int:
"""Energy resistance / immunity at the final-damage stage.
"""Energy resistance / immunity applied to one whole damage portion.
Immunity: if any damage type matches → 0. Resistance: subtract the
max resistance among matching energy types, floored at 0.
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
they stay on the DR path.
Used by the spell-damage path: a ``DamageEffect`` is a single
component (one formula, one type list), so the per-component rule
(see ``_apply_energy_resistance_per_component``) reduces to exactly
this. Immunity: if any damage type matches → 0. Resistance:
subtract the max resistance among matching energy types, floored
at 0. Physical types (slashing/piercing/bludgeoning/physical) are
ignored — they stay on the DR path.
"""
immunities = target.combatant.immunities
if immunities and damage_types & frozenset(immunities):
return 0
resistances = target.combatant.resistances
if not resistances:
return damage
max_resist = max(
(resistances[t] for t in damage_types if t in resistances),
default=0,
)
return max(0, damage - max_resist)
return max(0, damage - self._energy_reduction(damage, damage_types, target))
def resolve_save(
self,