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
+37
View File
@@ -19,6 +19,7 @@ from pf1e_simulator.models import (
AttackSpec,
Combatant,
DamageComponent,
DamageReduction,
Saves,
)
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).
assert result.damage == 6
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