fix(rules): apply energy resistance per damage component
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user