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
+7 -1
View File
@@ -832,7 +832,13 @@ Règles modélisées :
dés tranchants, les bonus plats et les dés de précision (attaque sournoise, non dés tranchants, les bonus plats et les dés de précision (attaque sournoise, non
typés) restent intacts ; l'immunité à un type annule uniquement les composantes typés) restent intacts ; l'immunité à un type annule uniquement les composantes
de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le
chemin de la RD, appliquée au total comme avant. La résistance énergétique chemin de la RD, appliquée **uniquement à la part physique** du dégât (dés
physiques + bonus plats + précision) — conformance A3 (CRB « Damage
Reduction » : « Damage reduction does not negate … energy damage dealt along
with an attack ») : la RD ne mange plus la composante énergétique d'une
attaque mixée (ex. arme à feu infligeant des dégâts de feu contre RD 15) ;
les composantes énergétiques ne sont réduites que par résistance/immunité
énergétique. La résistance énergétique
s'applique à l'étape du dégât final (même étape que la RD), y compris aux dégâts s'applique à l'étape du dégât final (même étape que la RD), y compris aux dégâts
de sorts (un `DamageEffect` est une composante unique — comportement inchangé). de sorts (un `DamageEffect` est une composante unique — comportement inchangé).
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou), - Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
+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 weapons in a single full attack are not modeled.
- Natural 1 always misses; natural 20 always hits and threatens a crit. - 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. - 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 DR; a multi-component "and" bypass (e.g. "bludgeoning and magic") requires
EVERY component (PF1e "DR X/Y and Z" notation). EVERY component (PF1e "DR X/Y and Z" notation).
- Death when negative hp reaches the Constitution score (hp <= -CON); PF1e - 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 _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 _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 _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, # PF1e confused d% table: 01-25 act normally, 26-50 babble, 51-75 hit self,
# 76-100 attack the nearest creature # 76-100 attack the nearest creature
_CONFUSED_ACT_NORMAL_MAX = 25 _CONFUSED_ACT_NORMAL_MAX = 25
@@ -2442,7 +2448,19 @@ class CombatEngine:
if crit: if crit:
damage *= weapon.crit_mult damage *= weapon.crit_mult
damage += sneak_total 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( return crit, False, self._apply_energy_resistance_per_component(
damage, weapon, rolls, crit=crit, target=target damage, weapon, rolls, crit=crit, target=target
) )
@@ -2633,10 +2651,11 @@ class CombatEngine:
A component carrying several energy types uses the largest matching A component carrying several energy types uses the largest matching
resistance (documented simplification: one die roll cannot be split resistance (documented simplification: one die roll cannot be split
across the listed types). Physical-only components match nothing in across the listed types). Physical-only components match nothing in
the target's resistance/immunity maps and stay on the DR path, which the target's resistance/immunity maps — they rode the DR path just
was applied to the lumped total just before this step (unchanged); before this step, which reduces only the non-energy share of the
single-component weapons keep the exact arithmetic of the old damage (CRB: DR does not negate energy damage dealt along with an
lumped-total path. attack); single-component weapons keep the exact arithmetic of the
old lumped-total path.
""" """
mult = weapon.crit_mult if crit else 1 mult = weapon.crit_mult if crit else 1
for comp, roll in zip(weapon.damage, rolls, strict=True): for comp, roll in zip(weapon.damage, rolls, strict=True):
+37
View File
@@ -19,6 +19,7 @@ from pf1e_simulator.models import (
AttackSpec, AttackSpec,
Combatant, Combatant,
DamageComponent, DamageComponent,
DamageReduction,
Saves, Saves,
) )
from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.rng import ScriptedRng
@@ -206,3 +207,39 @@ def test_sneak_precision_not_reduced_by_energy_resistance() -> None:
# the 6 precision dice survive intact (the old lumped path dealt 0). # the 6 precision dice survive intact (the old lumped path dealt 0).
assert result.damage == 6 assert result.damage == 6
assert eng._states[2].hp == 94 assert eng._states[2].hp == 94
def test_dr_spares_the_energy_component_of_a_mixed_attack() -> None:
"""(g) A3: DR reduces only the physical share — energy dice pass through.
PF1e (CRB "Damage Reduction"): "Damage reduction does not negate ...
energy damage dealt along with an attack." 1d8 piercing = 3 + 1d6 fire = 6
against DR 5: the old lumped path dealt max(0, 9-5) = 4 (DR ate 2 of the
fire dice); the fix reduces only the 3 piercing damage (floored at 0) and
the full 6 fire damage lands.
"""
weapon = _attack(
"flintlock",
(("1d8", ("piercing",)), ("1d6", ("fire",))),
)
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe")
foe = foe.model_copy(update={"dr": DamageReduction(amount=5)})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 3, 6])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
assert result.damage == 6
assert eng._states[1].hp == 94
def test_physical_only_damage_vs_dr_unchanged() -> None:
"""(h) a physical-only weapon keeps the exact old DR arithmetic (regression
lock for the split): 1d8 = 3 vs DR 5 floors to 0."""
weapon = _attack("warhammer", (("1d8", ("bludgeoning",)),))
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe").model_copy(update={"dr": DamageReduction(amount=5)})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 3])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
assert result.damage == 0
assert eng._states[1].hp == 100