feat(combat): enforce spell resistance checks and energy resistances

This commit is contained in:
2026-09-06 19:18:10 +02:00
parent a9ccdd3a56
commit a1c43e83af
4 changed files with 332 additions and 1 deletions
+17 -1
View File
@@ -237,6 +237,8 @@ Champ `range_increment_ft` (optionnel, uniquement pour `kind: ranged` ou
par incrément complet au-delà du premier ; une arme `ranged` reste utilisable par incrément complet au-delà du premier ; une arme `ranged` reste utilisable
jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle jusqu'à 10 incréments, une arme `touch` jusqu'à 5 (jet/firearm), au-delà elle
n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels. n'est pas sélectionnable. `damage_bonus` et `dr` sont optionnels.
`spell_resistance` (entier), `resistances` (dict type → montant) et
`immunities` (liste de types) sont optionnels.
Deux monstres d'exemple sont fournis : gobelin et orc. Deux monstres d'exemple sont fournis : gobelin et orc.
### Fiches de personnages (PJ) ### Fiches de personnages (PJ)
@@ -396,6 +398,18 @@ Règles modélisées :
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half` (`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
pour les explosions). `default_policy` choisit soin / offensif / buff via pour les explosions). `default_policy` choisit soin / offensif / buff via
`_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`. `_find_heal_spell`, `_find_offensive_spell` et `_find_ally_needing_heal`.
- Résistance aux sorts (SR) et résistances/immunités énergétiques : un combattant
avec `spell_resistance > 0` oppose sa SR à chaque sort qui le cible — jet
`d20 + niveau du jeteur` contre la SR ; si le total est inférieur, le sort est
résisté (journalisé, tous les effets annulés pour cette cible). Pour les sorts à
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.
- Invocation : les créatures invoquées agissent (calées sur la case d'initiative de - Invocation : les créatures invoquées agissent (calées sur la case d'initiative de
leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration` leur conjurateur via `_build_round_order`) puis expirent après leur `summon_duration`
(`_expire_summons` décrémente puis retire). (`_expire_summons` décrémente puis retire).
@@ -480,7 +494,9 @@ Règles modélisées :
feature_details (C4 : tuple de {name, category, source_detail, effects: feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}), tuple de {target, value, bonus_type, condition, weapon_filter}}),
spells (résumés enrichis du registre : name, level, school, range_ft, spells (résumés enrichis du registre : name, level, school, range_ft,
save, dc, summary, half_on_save, save_negates (E4)). save, dc, summary, half_on_save, save_negates (E4)),
spell_resistance (int), resistances {type: montant}, immunities
(tuple triée des types immunisés).
- Par paire combattant actif × cible : target_index, distance_ft, weapon_for, - Par paire combattant actif × cible : target_index, distance_ft, weapon_for,
range_penalty, can_charge, can_step5_to_attack, has_line_of_effect, range_penalty, can_charge, can_step5_to_attack, has_line_of_effect,
flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me. flanking, movement_cost_ft, target_threatens_me, enemies_threatening_me.
+64
View File
@@ -842,6 +842,9 @@ class CombatEngine:
"spells": tuple( "spells": tuple(
self._spell_snapshot(state, name) for name in state.combatant.spells self._spell_snapshot(state, name) for name in state.combatant.spells
), ),
"spell_resistance": state.combatant.spell_resistance,
"resistances": dict(state.combatant.resistances),
"immunities": tuple(state.combatant.immunities),
} }
def _pair_snapshot( def _pair_snapshot(
@@ -1373,6 +1376,12 @@ class CombatEngine:
if crit: if crit:
damage *= weapon.crit_mult damage *= weapon.crit_mult
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
)
damage = self._apply_energy_resistance(
damage, weapon_types, target
)
target.hp -= damage target.hp -= damage
damage = self._consume_up_close_and_deadly( damage = self._consume_up_close_and_deadly(
attacker, target, weapon, dist_ft, damage, hit=hit attacker, target, weapon, dist_ft, damage, hit=hit
@@ -1427,6 +1436,55 @@ class CombatEngine:
return damage return damage
return max(0, damage - dr.amount) return max(0, damage - dr.amount)
def _check_spell_resistance(
self, caster: CombatantState, target: CombatantState
) -> bool:
"""SR check: d20 + caster level vs target spell_resistance.
Returns True if the spell affects the target (SR passed or no SR).
Returns False if the target resists the spell (all effects skipped).
"""
sr = target.combatant.spell_resistance
if sr == 0:
return True
caster_level = caster.combatant.level
roll = self._rng.d20()
total = roll + caster_level
if total >= sr:
return True
self._log(
f"round {self._current_round} {caster.combatant.id}: "
f"spell resistance {target.combatant.id}: "
f"d20({roll}) + CL({caster_level}) = {total} vs SR {sr} "
f"— resisted"
)
return False
def _apply_energy_resistance(
self,
damage: int,
damage_types: frozenset[str],
target: CombatantState,
) -> int:
"""Energy resistance / immunity at the final-damage stage.
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)
def resolve_save( def resolve_save(
self, self,
state: CombatantState, state: CombatantState,
@@ -2890,6 +2948,9 @@ class CombatEngine:
if self._check_casting_aoo(caster, spell.level): if self._check_casting_aoo(caster, spell.level):
return return
caster.spend_spell_slot(spell.level) caster.spend_spell_slot(spell.level)
_has_area = any(isinstance(e, AreaEffect) for e in spell.effects)
if not _has_area and not self._check_spell_resistance(caster, target):
return
dc = self._spell_dc(caster, spell) dc = self._spell_dc(caster, spell)
save_result = ( save_result = (
self.resolve_save( self.resolve_save(
@@ -3023,6 +3084,7 @@ class CombatEngine:
dmg = raw dmg = raw
if empower: if empower:
dmg = int(dmg * 1.5) dmg = int(dmg * 1.5)
dmg = self._apply_energy_resistance(dmg, frozenset(effect.types), target)
target.hp -= dmg target.hp -= dmg
caster_live.damage_dealt += dmg caster_live.damage_dealt += dmg
target_live.damage_taken += dmg target_live.damage_taken += dmg
@@ -3115,6 +3177,8 @@ class CombatEngine:
total_buffs = 0 total_buffs = 0
for affected_state in affected: for affected_state in affected:
if not self._check_spell_resistance(caster, affected_state):
continue
creature_saved = self._area_save( creature_saved = self._area_save(
affected_state, target, effect, saved=saved, dc=dc affected_state, target, effect, saved=saved, dc=dc
) )
+3
View File
@@ -294,3 +294,6 @@ class Combatant(_FrozenModel):
resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat resources: dict[str, int] = Field(default_factory=dict) # resource pools per combat
metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell metamagic_feats: list[str] = Field(default_factory=list) # e.g. Empower/Maximize/Extend Spell
spells: list[str] = Field(default_factory=list) spells: list[str] = Field(default_factory=list)
spell_resistance: int = 0
resistances: dict[str, int] = Field(default_factory=dict)
immunities: tuple[str, ...] = ()
+248
View File
@@ -0,0 +1,248 @@
"""Spell resistance and energy resistance/immunity (Package P1)."""
from __future__ import annotations
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 _snapshot, make_grid, make_state
def _make_combatant(
id_: str,
*,
hp_max: int = 100,
attacks: tuple[AttackSpec, ...] = (),
spell_resistance: int = 0,
resistances: dict[str, int] | None = None,
immunities: tuple[str, ...] = (),
) -> Combatant:
return Combatant(
id=id_,
name=id_,
level=1,
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=hp_max,
saves=Saves(fort=0, ref=0, will=0),
attacks=list(attacks),
spell_resistance=spell_resistance,
resistances=resistances or {},
immunities=immunities,
)
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,
)
_MAGIC_MISSILE = SpellSpec(
name="Magic Missile",
level=1,
school="evocation",
range="medium",
save="none",
base_dc=10,
effects=(
DamageEffect(
formula=parse_dice("1d4+1"),
types=("force",),
),
),
)
_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_sr_blocks_all_effects() -> None:
caster = _make_combatant("caster", spell_resistance=0)
target = _make_combatant("target", spell_resistance=15)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
[8],
spell_registry={"Magic Missile": _MAGIC_MISSILE},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "Magic Missile")
assert ts.hp == 100
def test_sr_passes_when_cl_suffices() -> None:
caster = _make_combatant("caster", spell_resistance=0)
target = _make_combatant("target", spell_resistance=10)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
[10, 3],
spell_registry={"Magic Missile": _MAGIC_MISSILE},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "Magic Missile")
assert ts.hp < 100
def test_per_target_sr_in_area_spell() -> None:
caster = _make_combatant("caster", spell_resistance=0)
t1 = _make_combatant("t1", spell_resistance=15)
t2 = _make_combatant("t2", spell_resistance=0)
eng = _engine(
[caster, t1, t2],
[(0, 0), (0, 3), (0, 5)],
["players", "monsters", "monsters"],
[20, 3, 4, 4, 4, 4, 4, 4, 4, 4],
spell_registry={"Fireball": _FIREBALL},
)
cs = eng._states[0]
ts1 = eng._states[1]
ts2 = eng._states[2]
eng._cast_spell(cs, ts2, "Fireball")
assert ts1.hp == 100
assert ts2.hp < 100
def test_no_sr_check_when_sr_zero() -> None:
caster = _make_combatant("caster", spell_resistance=0)
target = _make_combatant("target", spell_resistance=0)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
[3],
spell_registry={"Magic Missile": _MAGIC_MISSILE},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "Magic Missile")
assert ts.hp == 96
assert not any("spell resistance" in line for line in eng._transcript)
def test_fire_resistance_reduces_spell_damage() -> None:
caster = _make_combatant("caster", spell_resistance=0)
target = _make_combatant(
"target",
spell_resistance=0,
resistances={"fire": 5},
)
eng = _engine(
[caster, target], [(0, 0), (0, 5)], ["players", "monsters"],
[1, 3, 3, 3, 3, 3, 3, 3, 3],
spell_registry={"Fireball": _FIREBALL},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "Fireball")
assert ts.hp < 100
def test_fire_immunity_negates_damage() -> None:
caster = _make_combatant("caster", spell_resistance=0)
target = _make_combatant(
"target",
spell_resistance=0,
immunities=("fire",),
)
eng = _engine(
[caster, target], [(0, 0), (0, 5)], ["players", "monsters"],
[1, 3, 3, 3, 3, 3, 3, 3, 3],
spell_registry={"Fireball": _FIREBALL},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "Fireball")
assert ts.hp == 100
def test_weapon_fire_damage_reduced_by_resistance() -> None:
fire_weapon = AttackSpec(
id="flaming-sword",
name="flaming sword",
kind="melee",
attack_bonus=20,
damage=(
DamageComponent(formula=parse_dice("1d8"), types=("slashing",)),
DamageComponent(formula=parse_dice("1d6"), types=("fire",)),
),
damage_bonus=0,
crit_range=20,
crit_mult=2,
)
hero = _make_combatant("hero", attacks=(fire_weapon,))
foe = _make_combatant(
"foe",
resistances={"fire": 10},
)
eng = _engine(
[hero, foe], [(0, 0), (0, 1)], ["players", "monsters"],
[15, 7, 6],
)
hs = eng._states[0]
fs = eng._states[1]
eng._single_attack(hs, fs, fire_weapon)
assert fs.hp < 100
def test_snapshot_exposes_new_keys() -> None:
hero = _make_combatant(
"hero",
spell_resistance=15,
resistances={"fire": 10, "cold": 5},
immunities=("electricity",),
)
foe = _make_combatant("foe")
eng = _engine(
[hero, foe], [(0, 0), (0, 3)], ["players", "monsters"],
[],
)
hs = eng._states[0]
obs = _snapshot(eng, hs)
c = obs["combatants"][0]
assert c["spell_resistance"] == 15
assert c["resistances"] == {"fire": 10, "cold": 5}
assert c["immunities"] == ("electricity",)