fix(rules): apply energy resistance per damage component
This commit is contained in:
@@ -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
|
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
|
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
|
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,
|
correspondant, **composante par composante** (conformance B21, CRB « Energy
|
||||||
plancher 0) ; les types physiques (contondant/perforant/tranchant) restent sur le
|
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite
|
||||||
chemin de la RD. La résistance énergétique s'applique au dégât final (même étape
|
uniquement par la résistance correspondant à SES types (maximum en cas de types
|
||||||
que la RD), y compris aux dégâts de sorts et aux composantes énergétiques des
|
multiples — simplification documentée : une même formule de dés n'est pas
|
||||||
armes.
|
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),
|
- Concealment (chance de rater) : les conditions `blurred` (20 %, sort flou),
|
||||||
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
|
`displaced` (50 %, concealment total) et `invisible` (50 %, concealment total)
|
||||||
portent un champ `miss_chance` ; quand la
|
portent un champ `miss_chance` ; quand la
|
||||||
|
|||||||
@@ -2256,9 +2256,8 @@ class CombatEngine:
|
|||||||
dmg_mods = self._stat_modifiers(
|
dmg_mods = self._stat_modifiers(
|
||||||
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
attacker, "damage", weapon_name=weapon.name, weapon=weapon, dist_ft=dist_ft
|
||||||
)
|
)
|
||||||
damage = (
|
rolls = [c.formula.roll(self._rng) for c in weapon.damage]
|
||||||
sum(c.formula.roll(self._rng) for c in weapon.damage) + weapon.damage_bonus
|
damage = sum(rolls) + weapon.damage_bonus
|
||||||
)
|
|
||||||
damage += resolve_modifiers(dmg_mods)
|
damage += resolve_modifiers(dmg_mods)
|
||||||
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
# PF1e (CRB Power Attack): +2X damage with a melee weapon, +3X (the
|
||||||
# bonus increased by half) with a two-handed weapon, a one-handed
|
# bonus increased by half) with a two-handed weapon, a one-handed
|
||||||
@@ -2272,9 +2271,9 @@ class CombatEngine:
|
|||||||
damage *= weapon.crit_mult
|
damage *= weapon.crit_mult
|
||||||
damage += sneak_total
|
damage += sneak_total
|
||||||
damage = self._apply_dr(damage, weapon, target)
|
damage = self._apply_dr(damage, weapon, target)
|
||||||
weapon_types = frozenset(t for comp in weapon.damage for t in comp.types)
|
return crit, False, self._apply_energy_resistance_per_component(
|
||||||
damage = self._apply_energy_resistance(damage, weapon_types, target)
|
damage, weapon, rolls, crit=crit, target=target
|
||||||
return crit, False, damage
|
)
|
||||||
|
|
||||||
def _concealment_chance(self, target: CombatantState) -> int:
|
def _concealment_chance(self, target: CombatantState) -> int:
|
||||||
"""Highest miss chance (%) among the target's active concealment conditions."""
|
"""Highest miss chance (%) among the target's active concealment conditions."""
|
||||||
@@ -2439,30 +2438,73 @@ class CombatEngine:
|
|||||||
)
|
)
|
||||||
return False
|
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(
|
def _apply_energy_resistance(
|
||||||
self,
|
self,
|
||||||
damage: int,
|
damage: int,
|
||||||
damage_types: frozenset[str],
|
damage_types: frozenset[str],
|
||||||
target: CombatantState,
|
target: CombatantState,
|
||||||
) -> int:
|
) -> 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
|
Used by the spell-damage path: a ``DamageEffect`` is a single
|
||||||
max resistance among matching energy types, floored at 0.
|
component (one formula, one type list), so the per-component rule
|
||||||
Physical types (slashing/piercing/bludgeoning/physical) are ignored —
|
(see ``_apply_energy_resistance_per_component``) reduces to exactly
|
||||||
they stay on the DR path.
|
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
|
return max(0, damage - self._energy_reduction(damage, damage_types, target))
|
||||||
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)
|
|
||||||
|
|
||||||
def resolve_save(
|
def resolve_save(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user