From 9768f5ae1db8fa8919cb155fcea9f021d546bb12 Mon Sep 17 00:00:00 2001 From: ctan Date: Sun, 13 Sep 2026 12:44:35 +0200 Subject: [PATCH] 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 --- README.md | 15 +-- data/spells/abundant_ammunition.json | 1 + data/spells/acid_arrow.json | 1 + data/spells/acid_splash.json | 1 + data/spells/alarm.json | 1 + data/spells/arcane_mark.json | 1 + data/spells/create_water.json | 1 + data/spells/dancing_lights.json | 1 + data/spells/darkness.json | 1 + data/spells/detect_fiendish_presence.json | 1 + data/spells/detect_magic.json | 1 + data/spells/detect_poison.json | 1 + data/spells/drunkards_breath.json | 1 + data/spells/flurry_of_snowballs.json | 1 + data/spells/grasp.json | 1 + data/spells/light.json | 1 + data/spells/mage_hand.json | 1 + data/spells/masterwork_transformation.json | 1 + data/spells/message.json | 1 + data/spells/obscuring_mist.json | 1 + data/spells/prestidigitation.json | 1 + data/spells/protection_from_evil.json | 1 + .../spells/protection_from_evil_communal.json | 1 + data/spells/protection_from_law.json | 1 + data/spells/protection_from_law_communal.json | 1 + data/spells/sanctuary.json | 1 + data/spells/summon_instrument.json | 1 + data/spells/unseen_servant.json | 1 + data/spells/web.json | 1 + src/pf1e_simulator/combat.py | 10 +- src/pf1e_simulator/spells.py | 6 ++ tests/test_spell_resistance.py | 92 +++++++++++++++++++ 32 files changed, 142 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c4b5b96..6f5d00c 100644 --- a/README.md +++ b/README.md @@ -982,7 +982,14 @@ Règles modélisées : `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 + primaire). **Drapeau `spell_resistance` par sort (S2e)** : `SpellSpec` porte un + booléen `spell_resistance` (JSON `"spell_resistance"`, défaut `true`). Quand un + sort le déclare `false` (les sorts dont la fiche SRD indique « Spell Resistance: + no » — Acid Arrow, Flurry of Snowballs, Web, Darkness, Sanctuary, les + `Protection from …`, etc. ; 28 sorts importés vérifiés contre le KB), la + vérification de SR est **totalement sautée**, tanto sur la cible directe que par + créature dans une zone. Les 28 sorts SRD « RM : non » portent désormais + `"spell_resistance": false` ; les 79 autres gardent le défaut `true`. 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, **composante par composante** (conformance B21, CRB « Energy Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite @@ -1446,12 +1453,6 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) : modélisé, S1a/S1c) : la sauvegarde partielle est désormais modélisée (panic à l'échec, `shaken` 1 round au succès — voir « Sauvegardes partielles » plus bas) ; seule la durée « 1 round/niveau » du `panicked` reste fixée à 4 rounds. -- **Résistance aux sorts appliquée à tout sort ciblé** : le moteur oppose la - SR du combattant à chaque sort qui le cible, sans drapeau par sort ; les - rares sorts dont la fiche SRD déclare « Spell Resistance: no » (ex. Acid - Arrow) ne sont donc pas distingués et se heurtent quand même à la SR - (conformance D9 — documenté plutôt qu'un drapeau `spell_resistance` par - sort). ## Architecture diff --git a/data/spells/abundant_ammunition.json b/data/spells/abundant_ammunition.json index f263e7f..ca6b8b0 100644 --- a/data/spells/abundant_ammunition.json +++ b/data/spells/abundant_ammunition.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip that conjures duplicate ammunition: attacks with the affected ammunition do not expend it. No combat-mechanical effect in the engine (ammunition is not tracked)." } diff --git a/data/spells/acid_arrow.json b/data/spells/acid_arrow.json index f96260f..6a6b268 100644 --- a/data/spells/acid_arrow.json +++ b/data/spells/acid_arrow.json @@ -7,6 +7,7 @@ "base_dc": 10, "requires_ranged_touch": true, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "damage", diff --git a/data/spells/acid_splash.json b/data/spells/acid_splash.json index 2ecde81..dd0ba81 100644 --- a/data/spells/acid_splash.json +++ b/data/spells/acid_splash.json @@ -7,6 +7,7 @@ "base_dc": 10, "requires_ranged_touch": true, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "damage", diff --git a/data/spells/alarm.json b/data/spells/alarm.json index 682dcc7..c692d05 100644 --- a/data/spells/alarm.json +++ b/data/spells/alarm.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "Wards a area (20-ft radius, 20 ft high) against intrusion for 2 hours/level, alerting you mentally. No combat-mechanical effect in the engine." } diff --git a/data/spells/arcane_mark.json b/data/spells/arcane_mark.json index 903e725..0757b0a 100644 --- a/data/spells/arcane_mark.json +++ b/data/spells/arcane_mark.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "Creates a permanent visible inscribed mark of your choosing on an object or surface, optionally invisible and keyed to you. Utility cantrip, no combat effect." } diff --git a/data/spells/create_water.json b/data/spells/create_water.json index fde84b0..bba3441 100644 --- a/data/spells/create_water.json +++ b/data/spells/create_water.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "Conjures 4 gallons of clean water per level in an open space or container. Utility spell, no combat effect." } diff --git a/data/spells/dancing_lights.json b/data/spells/dancing_lights.json index b10db9f..de8781f 100644 --- a/data/spells/dancing_lights.json +++ b/data/spells/dancing_lights.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip that creates up to four flickering torch-like lights you move as desired (concentration, 1 min/level). Utility spell, no combat effect." } diff --git a/data/spells/darkness.json b/data/spells/darkness.json index c723a09..ccb329e 100644 --- a/data/spells/darkness.json +++ b/data/spells/darkness.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "area", diff --git a/data/spells/detect_fiendish_presence.json b/data/spells/detect_fiendish_presence.json index 0d185bd..6af8f40 100644 --- a/data/spells/detect_fiendish_presence.json +++ b/data/spells/detect_fiendish_presence.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip that reveals whether a creature of the outsider (evil) subtype has been in the area within 10 minutes/HD, and hints at its nature. Utility spell, no combat effect." } diff --git a/data/spells/detect_magic.json b/data/spells/detect_magic.json index 156aa20..86c93d1 100644 --- a/data/spells/detect_magic.json +++ b/data/spells/detect_magic.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip that detects magical auras in a 60-ft cone (2 rounds to identify strength and school). Utility spell, no combat effect." } diff --git a/data/spells/detect_poison.json b/data/spells/detect_poison.json index ee09ed3..09ab76f 100644 --- a/data/spells/detect_poison.json +++ b/data/spells/detect_poison.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip that determines whether a creature, object, or area is poisoned, venomous, or afflicted by acid. Utility spell, no combat effect." } diff --git a/data/spells/drunkards_breath.json b/data/spells/drunkards_breath.json index 44e5127..d4ea759 100644 --- a/data/spells/drunkards_breath.json +++ b/data/spells/drunkards_breath.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "For 1 hour/level you gain DR 5/- against alcohol effects and cannot become drunk. The engine has no DR buff target, so no mechanical effect is applied." } diff --git a/data/spells/flurry_of_snowballs.json b/data/spells/flurry_of_snowballs.json index 926a062..d3a4c46 100644 --- a/data/spells/flurry_of_snowballs.json +++ b/data/spells/flurry_of_snowballs.json @@ -6,6 +6,7 @@ "save": "ref", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "area", diff --git a/data/spells/grasp.json b/data/spells/grasp.json index 2fadea2..65ebbcf 100644 --- a/data/spells/grasp.json +++ b/data/spells/grasp.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "immediate", + "spell_resistance": false, "effects": [], "description": "A cantrip cast as an immediate action when you fail a Climb check and would fall: you immediately retry the Climb check at a -2 penalty (increasing by 2 per successive use in the same situation); on success you don't fall. Climbing is not modeled by the engine, so the spell has no mechanical effects here." } diff --git a/data/spells/light.json b/data/spells/light.json index cc73bb0..c61119f 100644 --- a/data/spells/light.json +++ b/data/spells/light.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip: a touched object glows like a torch, shedding normal light in a 20-foot radius (plus dim light for 20 more feet) for 10 min/level. No combat effect; illumination is not modeled by the engine." } diff --git a/data/spells/mage_hand.json b/data/spells/mage_hand.json index f2d2bdc..a003f7c 100644 --- a/data/spells/mage_hand.json +++ b/data/spells/mage_hand.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip: a spectral hand lifts and moves one unattended nonmagical object weighing up to 5 lb. from up to 25 ft. away, as far as 15 feet per move action while you concentrate. No combat effect; object manipulation is not modeled by the engine." } diff --git a/data/spells/masterwork_transformation.json b/data/spells/masterwork_transformation.json index ca26b3e..22b93db 100644 --- a/data/spells/masterwork_transformation.json +++ b/data/spells/masterwork_transformation.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "long", + "spell_resistance": false, "effects": [], "description": "After 1 hour of casting (plus masterwork-priced reagents), a non-masterwork weapon, armor, shield, tool, or skill kit becomes its masterwork equivalent. Item crafting is outside the engine's combat model, so the spell has no mechanical effects here." } diff --git a/data/spells/message.json b/data/spells/message.json index 9f72d43..0a2d40a 100644 --- a/data/spells/message.json +++ b/data/spells/message.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip: you whisper messages and receive replies with targeted creatures within range for 10 min/level; bystanders hear nothing without a DC 25 Perception check. Communication is not modeled by the engine, so the spell has no mechanical effects here." } diff --git a/data/spells/obscuring_mist.json b/data/spells/obscuring_mist.json index e24aa04..6e562b3 100644 --- a/data/spells/obscuring_mist.json +++ b/data/spells/obscuring_mist.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "area", diff --git a/data/spells/prestidigitation.json b/data/spells/prestidigitation.json index e7ac790..f5ff37c 100644 --- a/data/spells/prestidigitation.json +++ b/data/spells/prestidigitation.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A cantrip: minor magical tricks for 1 hour - lift 1 lb. of material, color/clean/soil items in a 1-ft cube, chill/warm/flavor 1 lb. of nonliving material, or create crude trinkets. It can deal no damage and duplicates no other spell. No combat effect; the trivial manipulations are not modeled by the engine." } diff --git a/data/spells/protection_from_evil.json b/data/spells/protection_from_evil.json index 232cf41..6d2d6bf 100644 --- a/data/spells/protection_from_evil.json +++ b/data/spells/protection_from_evil.json @@ -6,6 +6,7 @@ "save": "will", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A barrier wards the subject from evil for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by evil creatures, renewed saves and suppression of mental control, and lawful summoned evil creatures cannot touch it. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here." } diff --git a/data/spells/protection_from_evil_communal.json b/data/spells/protection_from_evil_communal.json index ae57cc0..bb3cfc9 100644 --- a/data/spells/protection_from_evil_communal.json +++ b/data/spells/protection_from_evil_communal.json @@ -6,6 +6,7 @@ "save": "will", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "Functions like protection from evil (+2 deflection to AC and +2 resistance on saves vs evil creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here." } diff --git a/data/spells/protection_from_law.json b/data/spells/protection_from_law.json index 1970d29..5c48dc1 100644 --- a/data/spells/protection_from_law.json +++ b/data/spells/protection_from_law.json @@ -6,6 +6,7 @@ "save": "will", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "A barrier wards the subject from lawful creatures for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by lawful creatures, plus renewed saves against mental control by lawful creatures. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here." } diff --git a/data/spells/protection_from_law_communal.json b/data/spells/protection_from_law_communal.json index 8e857e4..3ca6ffc 100644 --- a/data/spells/protection_from_law_communal.json +++ b/data/spells/protection_from_law_communal.json @@ -6,6 +6,7 @@ "save": "will", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "Functions like protection from law (+2 deflection to AC and +2 resistance on saves vs lawful creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here." } diff --git a/data/spells/sanctuary.json b/data/spells/sanctuary.json index 9490519..59de430 100644 --- a/data/spells/sanctuary.json +++ b/data/spells/sanctuary.json @@ -6,6 +6,7 @@ "save": "will", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "You ward a creature against attacks for 1 round/level: any opponent attempting to directly attack the warded creature must succeed on a Will save or be unable to follow through with that attack and cannot directly attack the subject for the duration. The subject cannot attack without ending the spell. Will negates. No combat effect encoded: the engine has no attack-refusal-by-save mechanic, so the protection cannot be represented." } diff --git a/data/spells/summon_instrument.json b/data/spells/summon_instrument.json index 2d3401b..21a1f24 100644 --- a/data/spells/summon_instrument.json +++ b/data/spells/summon_instrument.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "one_round", + "spell_resistance": false, "effects": [], "description": "A cantrip (casting time 1 round): you summon one handheld musical instrument of your choice into your hands or at your feet; it plays only for you and disappears when the spell ends (1 min./level). No combat effect: summoned objects are not modeled by the engine." } diff --git a/data/spells/unseen_servant.json b/data/spells/unseen_servant.json index 076571b..43d688a 100644 --- a/data/spells/unseen_servant.json +++ b/data/spells/unseen_servant.json @@ -6,6 +6,7 @@ "save": "none", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [], "description": "An invisible, mindless, shapeless force performs simple tasks at your command for 1 hour/level: fetching, opening unstuck doors, holding chairs, cleaning or mending (effective Strength 2, speed 15 ft., no attacks, DC 10 skill limit). No combat effect: object manipulation by invisible servants is not modeled by the engine." } diff --git a/data/spells/web.json b/data/spells/web.json index 54059b2..5da5d47 100644 --- a/data/spells/web.json +++ b/data/spells/web.json @@ -6,6 +6,7 @@ "save": "ref", "base_dc": 10, "cast_time": "standard", + "spell_resistance": false, "effects": [ { "type": "area", diff --git a/src/pf1e_simulator/combat.py b/src/pf1e_simulator/combat.py index 0b0ac00..bc6f936 100644 --- a/src/pf1e_simulator/combat.py +++ b/src/pf1e_simulator/combat.py @@ -5478,6 +5478,7 @@ class CombatEngine: if ( target is not None and not has_area + and spell.spell_resistance and not self._check_spell_resistance(caster, target) ): return True, False @@ -5735,7 +5736,7 @@ class CombatEngine: maximize="Maximize Spell" in metamagic, dc=dc, center_pos=center_pos, save_type=spell.save, crit=spell_crit, ranged_touch=per_projectile_attack, - attack_penalty=attack_penalty, + attack_penalty=attack_penalty, spell_resistance=spell.spell_resistance, ) save_str = _save_label(save_result) if target is None and center_pos is not None: @@ -5794,6 +5795,7 @@ class CombatEngine: crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, + spell_resistance: bool = True, ) -> list[str]: saved = save_result is not None and save_result.success return [ @@ -5803,6 +5805,7 @@ class CombatEngine: empower=empower, maximize=maximize, saved=saved, dc=dc, center_pos=center_pos, save_type=save_type, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, + spell_resistance=spell_resistance, ) for effect in effects ] @@ -5825,6 +5828,7 @@ class CombatEngine: crit: bool = False, ranged_touch: bool = False, attack_penalty: int = 0, + spell_resistance: bool = True, ) -> str: if isinstance(effect, AreaEffect): return self._apply_area_effect( @@ -5832,6 +5836,7 @@ class CombatEngine: caster=caster, spell_name=spell_name, saved=saved, empower=empower, maximize=maximize, dc=dc, center_pos=center_pos, save_type=save_type, + spell_resistance=spell_resistance, ) if isinstance(effect, SummonEffect): return self._apply_summon_effect(effect, caster, caster_live, spell_name) @@ -6100,6 +6105,7 @@ class CombatEngine: dc: int = 15, center_pos: tuple[int, int] | None = None, save_type: SaveType = "none", + spell_resistance: bool = True, ) -> str: """Apply an area effect to all creatures within its template. @@ -6125,7 +6131,7 @@ class CombatEngine: total_buffs = 0 for affected_state in affected: - if not self._check_spell_resistance(caster, affected_state): + if spell_resistance and not self._check_spell_resistance(caster, affected_state): continue creature_saved = self._area_save( affected_state, target, effect, saved=saved, dc=dc, diff --git a/src/pf1e_simulator/spells.py b/src/pf1e_simulator/spells.py index eaffa00..521917a 100644 --- a/src/pf1e_simulator/spells.py +++ b/src/pf1e_simulator/spells.py @@ -238,6 +238,11 @@ class SpellSpec: # area/save-only spells keep this False. requires_ranged_touch: bool = False cast_time: CastTime = "standard" + # PF1e (CRB, Magic — Spell Resistance): most spells are subject to a + # target's SR, but the SRD stat block marks a handful "Spell Resistance: + # no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check + # entirely (targeted and per-creature in areas). Default True. + spell_resistance: bool = True effects: tuple[SpellEffect, ...] = () description: str = "" @@ -573,6 +578,7 @@ def load_spell(path: str | Path) -> SpellSpec: base_dc=_as_int(data.get("base_dc", 10)), requires_ranged_touch=bool(data.get("requires_ranged_touch", False)), cast_time=cast_time, + spell_resistance=bool(data.get("spell_resistance", True)), effects=effects, description=_as_str(data.get("description", "")), ) diff --git a/tests/test_spell_resistance.py b/tests/test_spell_resistance.py index 8f9dc9f..d7facf4 100644 --- a/tests/test_spell_resistance.py +++ b/tests/test_spell_resistance.py @@ -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",