From 9fb1c2d75a3b488e3fa4f3eeb696d49738c42826 Mon Sep 17 00:00:00 2001 From: ctan Date: Fri, 11 Sep 2026 11:24:11 +0200 Subject: [PATCH] fix(rules): apply energy resistance per damage component --- README.md | 17 ++- src/pf1e_simulator/combat.py | 86 +++++++++---- tests/test_conformance_energy.py | 208 +++++++++++++++++++++++++++++++ 3 files changed, 284 insertions(+), 27 deletions(-) create mode 100644 tests/test_conformance_energy.py diff --git a/README.md b/README.md index 1918437..c81fa09 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 6cd1550..a28c13c 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -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, diff --git a/tests/test_conformance_energy.py b/tests/test_conformance_energy.py new file mode 100644 index 0000000..ec22ca8 --- /dev/null +++ b/tests/test_conformance_energy.py @@ -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