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>
This commit is contained in:
2026-09-13 12:44:35 +02:00
parent c96b36d3f9
commit 9768f5ae1d
32 changed files with 142 additions and 9 deletions
+92
View File
@@ -230,6 +230,98 @@ def test_weapon_fire_damage_reduced_by_resistance() -> None:
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",