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:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user