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
+8 -2
View File
@@ -831,8 +831,14 @@ Règles modélisées :
1d6 tranchant + 1d6 feu contre résistance feu 5 ne perd que ses dés de feu, les 1d6 tranchant + 1d6 feu contre résistance feu 5 ne perd que ses dés de feu, les
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