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:
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user