Files
pf1e-simulator/tests/test_spell_resistance.py
T
ctan 9768f5ae1d feat(spells): allow spells to ignore spell resistance
SpellSpec gains spell_resistance: bool = True (JSON 'spell_resistance'). When false, _spell_delivery_blocked skips the targeted SR check and _apply_area_effect skips the per-creature SR check (threaded through the effect chain). Verified each imported spell against the local KB and set false on the 28 whose SRD block says 'Spell Resistance: no' (Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, the Protection from X spells, etc.). Added no-SR targeted/area tests and a high-SR control in test_spell_resistance.py, and the README.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-09-13 12:44:35 +02:00

343 lines
10 KiB
Python

"""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, ...] = (),
spell_slots: dict[int, int] | None = None,
) -> 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,
spell_slots=spell_slots or {},
)
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, spell_slots={1: 9, 3: 9})
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, spell_slots={1: 9, 3: 9})
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, spell_slots={1: 9, 3: 9})
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, spell_slots={1: 9, 3: 9})
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, spell_slots={1: 9, 3: 9})
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, spell_slots={1: 9, 3: 9})
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
_Magic_MISSILE_NO = SpellSpec(
name="No SR Missile",
level=1,
school="evocation",
range="medium",
save="none",
base_dc=10,
spell_resistance=False,
effects=(
DamageEffect(formula=parse_dice("1d4+1"), types=("force",)),
),
)
_FIREBALL_NO_SR = SpellSpec(
name="No SR Burst",
level=3,
school="evocation",
range="long",
save="ref",
base_dc=10,
spell_resistance=False,
effects=(
AreaEffect(
radius_ft=20,
effects=(
DamageEffect(
formula=parse_dice("8d6"),
types=("fire",),
half_on_save=True,
),
),
save_for_half=True,
),
),
)
def test_no_sr_spell_ignores_high_sr_target() -> None:
"""Given: a spell that declares spell_resistance=False and a target with
SR 25 (a d20+CL could never beat it)
When: the spell is cast
Then: the SR check is skipped entirely — the target takes the damage."""
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=25)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
[4], # only the damage roll — no SR d20 consumed
spell_registry={"No SR Missile": _Magic_MISSILE_NO},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "No SR Missile")
assert ts.hp < 100
assert not any("spell resistance" in line for line in eng._transcript)
def test_normal_spell_still_resisted_by_high_sr_target() -> None:
"""Given: the SAME target with SR 25 but a normal spell (spell_resistance
defaults True)
When: cast with a low d20 (SR check fails)
Then: the spell is resisted (control for the flag)."""
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=25)
eng = _engine(
[caster, target], [(0, 0), (0, 2)], ["players", "monsters"],
[8], # SR check d20=8 + CL1 = 9 < 25 -> resisted
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_no_sr_area_spell_not_resisted_per_creature() -> None:
"""Given: a no-SR area spell and a high-SR creature in the area
When: the burst resolves
Then: the per-creature SR check is skipped — the creature takes damage."""
caster = _make_combatant("caster", spell_resistance=0, spell_slots={1: 9, 3: 9})
target = _make_combatant("target", spell_resistance=30)
eng = _engine(
[caster, target], [(0, 0), (0, 6)], ["players", "monsters"],
[1, 3, 3, 3, 3, 3, 3, 3, 3, 3], # save + 8d6; no SR d20 consumed
spell_registry={"No SR Burst": _FIREBALL_NO_SR},
)
cs = eng._states[0]
ts = eng._states[1]
eng._cast_spell(cs, ts, "No SR Burst")
assert ts.hp < 100
assert not any("spell resistance" in line for line in eng._transcript)
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",)