fix(rules): apply energy resistance per damage component

This commit is contained in:
2026-09-11 11:24:11 +02:00
parent 1960b8f655
commit 9fb1c2d75a
3 changed files with 284 additions and 27 deletions
+12 -5
View File
@@ -641,11 +641,18 @@ Règles modélisées :
zone, la SR est résolue par créature touchée (pas seulement sur la cible
primaire). Les dégâts énergétiques (feu, froid, électricité, acide, son…) sont
annulés par l'immunité (`immunities`) ou réduits par la résistance du type
correspondant (`resistances` : soustraction du maximum parmi les types du dégât,
plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
armes.
correspondant, **composante par composante** (conformance B21, CRB « Energy
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
uniquement par la résistance correspondant à SES types (maximum en cas de types
multiples — simplification documentée : une même formule de dés n'est pas
répartie entre les types listés), plancher 0 par composante ; une arme
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
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),
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
portent un champ `miss_chance` ; quand la
+64 -22
View File
@@ -2256,9 +2256,8 @@ class CombatEngine:
dmg_mods = self._stat_modifiers(
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
)
damage = (
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
)
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
damage = sum(rolls) + weapon.damage_bonus
damage += resolve_modifiers(dmg_mods)
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
# bonus increased by half) with a two-handed weapon, a one-handed
@@ -2272,9 +2271,9 @@ class CombatEngine:
damage *= weapon.crit_mult
damage += sneak_total
damage = self._apply_dr(damage, weapon, target)
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
damage = self._apply_energy_resistance(damage, weapon_types, target)
return crit, False, damage
return crit, False, self._apply_energy_resistance_per_component(
damage, weapon, rolls, crit=crit, target=target
)
def _concealment_chance(self, target: CombatantState) -> int:
"""Highest miss chance (%) among the target's active concealment conditions."""
@@ -2439,30 +2438,73 @@ class CombatEngine:
)
return False
def _apply_energy_resistance_per_component(
self,
damage: int,
weapon: AttackSpec,
rolls: list[int],
*,
crit: bool,
target: CombatantState,
) -> int:
"""Energy resistance / immunity applied PER DAMAGE COMPONENT (B21).
PF1e (CRB "Energy Resistance"/"Immunity to Energy Damage"): each
damage type's resistance reduces only the dice of that type — a
weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
loses only its fire portion; the slashing dice, the flat bonuses
(damage_bonus, ability/damage modifiers, Power Attack) and the
untyped sneak precision dice are never eaten by energy resistance.
Each component's share of the final damage is its own roll times
the crit multiplier (engine convention: every damage die is
multiplied on a crit) and its reduction is capped at that share.
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.
"""
mult = weapon.crit_mult if crit else 1
for comp, roll in zip(weapon.damage, rolls, strict=True):
damage -= self._energy_reduction(roll * mult, frozenset(comp.types), target)
return max(0, damage)
def _energy_reduction(
self, damage: int, damage_types: frozenset[str], target: CombatantState
) -> int:
"""Reduction the target's immunity/resistance applies to one
damage portion of the given types, capped at the portion itself."""
immunities = target.combatant.immunities
if immunities and damage_types & frozenset(immunities):
return damage
resistances = target.combatant.resistances
if not resistances:
return 0
max_resist = max(
(resistances[t] for t in damage_types if t in resistances),
default=0,
)
return min(damage, max_resist)
def _apply_energy_resistance(
self,
damage: int,
damage_types: frozenset[str],
target: CombatantState,
) -> int:
"""Energy resistance / immunity at the final-damage stage.
"""Energy resistance / immunity applied to one whole damage portion.
Immunity: if any damage type matches → 0. Resistance: subtract the
max resistance among matching energy types, floored at 0.
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
they stay on the DR path.
Used by the spell-damage path: a ``DamageEffect`` is a single
component (one formula, one type list), so the per-component rule
(see ``_apply_energy_resistance_per_component``) reduces to exactly
this. Immunity: if any damage type matches → 0. Resistance:
subtract the max resistance among matching energy types, floored
at 0. Physical types (slashing/piercing/bludgeoning/physical) are
ignored — they stay on the DR path.
"""
immunities = target.combatant.immunities
if immunities and damage_types & frozenset(immunities):
return 0
resistances = target.combatant.resistances
if not resistances:
return damage
max_resist = max(
(resistances[t] for t in damage_types if t in resistances),
default=0,
)
return max(0, damage - max_resist)
return max(0, damage - self._energy_reduction(damage, damage_types, target))
def resolve_save(
self,
+208
View File
@@ -0,0 +1,208 @@
"""Conformance B21: energy resistance/immunity apply per damage component.
PF1e (CRB "Energy Resistance", "Immunity to Energy Damage"): resistance to
an energy type reduces damage of THAT type only; immunity negates that type
only. A weapon dealing 1d6 slashing + 1d6 fire against fire resistance 5
keeps its slashing dice intact.
"""
from __future__ import annotations
from typing import Literal
from pf1e_simulator.abilities import AbilitySpec
from pf1e_simulator.combat import CombatEngine
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.models import (
AbilityScores,
ACProfile,
AttackSpec,
Combatant,
DamageComponent,
Saves,
)
from pf1e_simulator.rng import ScriptedRng
from pf1e_simulator.spells import AreaEffect, DamageEffect, SpellSpec
from tests.test_observation_enriched import make_grid, make_state
def _combatant(
id_: str,
*,
level: int = 1,
attacks: tuple[AttackSpec, ...] = (),
resistances: dict[str, int] | None = None,
immunities: tuple[str, ...] = (),
features: tuple[str, ...] = (),
spell_slots: dict[int, int] | None = None,
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=level,
abilities=AbilityScores(
str_score=10, dex_score=10, con_score=10,
int_score=10, wis_score=10, cha_score=10,
),
ac=ACProfile(total=10, touch=10, flat_footed=10),
bab=1,
initiative_mod=0,
speed_land_ft=30,
hp_max=100,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks),
resistances=resistances or {},
immunities=immunities,
features=[AbilitySpec(name=f, category="feat") for f in features],
spell_slots=spell_slots or {},
)
def _attack(
aid: str,
components: tuple[tuple[str, tuple[str, ...]], ...],
*,
kind: Literal["melee", "ranged", "touch"] = "melee",
) -> AttackSpec:
return AttackSpec(
id=aid,
name=aid,
kind=kind,
attack_bonus=20,
damage=[
DamageComponent(formula=parse_dice(formula), types=list(types))
for formula, types in components
],
)
def _engine(
combatants: list[Combatant],
positions: list[tuple[int, int]],
sides: list[str],
rng: list[int],
spell_registry: dict[str, SpellSpec] | None = None,
) -> CombatEngine:
states = [
make_state(c, side, pos)
for c, side, pos in zip(combatants, sides, positions, strict=True)
]
return CombatEngine(
ScriptedRng(rng), make_grid(), states, spell_registry=spell_registry
)
_MIXED = (
("1d6", ("slashing",)),
("1d6", ("fire",)),
)
def test_fire_resistance_spares_slashing_component() -> None:
"""(a) 1d6 slashing + 1d6 fire vs fire resistance 5: only the fire
portion is reduced (2 - 5 floors to 0); the slashing dice are intact."""
weapon = _attack("fiery-blade", _MIXED)
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe", resistances={"fire": 5})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 6, 2])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
# d20=15 hits (no threat); slashing 1d6=6, fire 1d6=2 → fire floored at 0.
assert result.damage == 6
assert eng._states[1].hp == 94
def test_fire_immunity_negates_only_fire_component() -> None:
"""(b) fire immunity zeroes the fire portion; the slashing portion lands."""
weapon = _attack("fiery-blade", _MIXED)
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe", immunities=("fire",))
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 6, 4])
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_weapon_ignores_energy_resistance() -> None:
"""(c) a physical-only weapon vs fire resistance is untouched."""
weapon = _attack("sword", (("1d6", ("slashing",)),))
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe", resistances={"fire": 5})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 5])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
assert result.damage == 5
assert eng._states[1].hp == 95
def test_mixed_energy_component_uses_largest_matching_resistance() -> None:
"""(d) one component with several energy types (fire+cold) vs resist
fire 5 / cold 10 → the largest matching resistance (10) applies."""
weapon = _attack("dual-energy", (("2d6", ("fire", "cold")),))
hero = _combatant("hero", attacks=(weapon,))
foe = _combatant("foe", resistances={"fire": 5, "cold": 10})
eng = _engine([hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 6, 6])
result = eng.resolve_attack(eng._states[0], eng._states[1], weapon)
# 2d6 = 12, reduced by cold 10 (largest match), not by fire 5.
assert result.damage == 2
assert eng._states[1].hp == 98
_FIREBALL = SpellSpec(
name="Fireball",
level=3,
school="evocation",
range="long",
save="ref",
base_dc=10,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(
formula=parse_dice("8d6"),
types=("fire",),
half_on_save=True,
),
),
save_for_half=True,
),
),
)
def test_spell_fire_damage_resistance_regression() -> None:
"""(e) a fire spell vs fire resistance still reduces like before the
refactor: 8d6 = 24 (save failed on natural 1) - 5 = 19."""
caster = _combatant("caster", spell_slots={3: 9})
foe = _combatant("foe", resistances={"fire": 5})
eng = _engine(
[caster, foe], [(0, 0), (0, 5)], ["players", "monsters"],
[1, 3, 3, 3, 3, 3, 3, 3, 3],
spell_registry={"Fireball": _FIREBALL},
)
eng._cast_spell(eng._states[0], eng._states[1], "Fireball")
assert eng._states[1].hp == 81
def test_sneak_precision_not_reduced_by_energy_resistance() -> None:
"""(f) sneak attack d6s are untyped precision — energy resistance may
only eat the weapon's own energy dice, never the precision dice."""
weapon = _attack("flame-dagger", (("1d6", ("fire",)),))
rogue = _combatant(
"rogue", attacks=(weapon,), features=("Sneak Attack",)
)
ally = _combatant(
"ally", attacks=(_attack("club", (("1d6", ("bludgeoning",)),)),)
)
foe = _combatant("foe", resistances={"fire": 10})
# Rogue (0,0) and ally (0,2) flank the foe (0,1): the sneak attack lands.
eng = _engine([rogue, ally, foe], [(0, 0), (0, 2), (0, 1)],
["players", "players", "monsters"], [15, 6, 4])
result = eng.resolve_attack(eng._states[0], eng._states[2], weapon)
# d20=15 hits, sneak 1d6=6, fire 1d6=4 → fire floored at 0 by resist 10;
# the 6 precision dice survive intact (the old lumped path dealt 0).
assert result.damage == 6
assert eng._states[2].hp == 94