From be2a755d17f6e084b42428c2ea29f6d26356c228 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 23:17:34 +0200 Subject: [PATCH] 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 --- README.md | 10 +++++++-- src/pf1e_simulator/combat.py | 31 ++++++++++++++++++++------ tests/test_conformance_energy.py | 37 ++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 851d094..c31851b 100644 --- a/README.md +++ b/README.md @@ -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 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 - 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 + de ce type. Les types physiques (contondant/perforant/tranchant) restent sur le + 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 de sorts (un `DamageEffect` est une composante unique — comportement inchangé). - Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou), diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index fccff81..a669cde 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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): diff --git a/tests/test_conformance_energy.py b/tests/test_conformance_energy.py index ec22ca8..2b40484 100644 --- a/tests/test_conformance_energy.py +++ b/tests/test_conformance_energy.py @@ -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