fix(rules): stop damage reduction from eating energy damage

PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
energy damage dealt along with an attack." _resolve_hit_damage now
splits the post-crit total and applies DR only to the non-energy share
(physical dice + flat bonuses + precision dice); energy components are
reduced solely by the per-component energy resistance/immunity step.
DR-bypass semantics (multi-component "and" requires every component)
are unchanged. Pure-physical weapons keep bit-identical arithmetic and
the golden transcripts are untouched.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-09-11 23:17:34 +02:00
parent 1862c22b7b
commit be2a755d17
3 changed files with 70 additions and 8 deletions
+25 -6
View File
@@ -36,7 +36,10 @@ Phase 0 documented deviations from PF1e (conventions):
natural weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit.
- A confirmed crit multiplies the total damage (dice + flat bonus) by crit_mult.
- DR applies once, after crit multiplication; a single bypass type defeats
- DR applies once, after crit multiplication, to the PHYSICAL portion of the
damage only (bludgeoning/piercing/slashing dice, flat bonuses and precision
dice) — energy damage dealt along with an attack is never reduced by DR
(CRB "Damage Reduction"); a single bypass type defeats
DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
EVERY component (PF1e "DR X/Y and Z" notation).
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e
@@ -182,6 +185,9 @@ _FIGHT_DEFENSIVELY_AC_BONUS = 2 # PF1e: fighting defensively grants +2 dodge AC
_FIGHT_DEFENSIVELY_ATTACK_PENALTY = -4 # PF1e: and -4 on attacks / crit confirms
_SPELL_COMBAT_ATTACK_PENALTY = -2 # PF1e: magus Spell Combat melee attacks at -2
_TOTAL_CONCEALMENT_PCT = 50 # PF1e: a blinded attacker's targets have total concealment
# PF1e (CRB Damage Reduction): DR does not negate energy damage dealt along
# with an attack — only these weapon damage types ride the DR path.
_PHYSICAL_DAMAGE_TYPES = frozenset({"bludgeoning", "piercing", "slashing"})
# PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
# 76-100 attack the nearest creature
_CONFUSED_ACT_NORMAL_MAX = 25
@@ -2442,7 +2448,19 @@ class CombatEngine:
if crit:
damage *= weapon.crit_mult
damage += sneak_total
damage = self._apply_dr(damage, weapon, target)
# PF1e (CRB Damage Reduction): "Damage reduction does not negate ...
# energy damage dealt along with an attack" — DR reduces only the
# non-energy share of the damage (physical dice, flat bonuses and
# precision dice); energy components are cut solely by energy
# resistance/immunity in the per-component step below.
mult = weapon.crit_mult if crit else 1
energy_share = sum(
roll * mult
for comp, roll in zip(weapon.damage, rolls, strict=True)
if not _PHYSICAL_DAMAGE_TYPES.issuperset(comp.types)
)
physical = max(0, damage - energy_share)
damage = self._apply_dr(physical, weapon, target) + energy_share
return crit, False, self._apply_energy_resistance_per_component(
damage, weapon, rolls, crit=crit, target=target
)
@@ -2633,10 +2651,11 @@ class CombatEngine:
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.
the target's resistance/immunity maps — they rode the DR path just
before this step, which reduces only the non-energy share of the
damage (CRB: DR does not negate energy damage dealt along with an
attack); 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):