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
+8 -7
View File
@@ -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 `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 à 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 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 annulés par l'immunité (`immunities`) ou réduits par la résistance du type
correspondant, **composante par composante** (conformance B21, CRB « Energy correspondant, **composante par composante** (conformance B21, CRB « Energy
Resistance ») : chaque composante de dégâts (`DamageComponent`) est réduite 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 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 à 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. 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 ## Architecture
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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)." "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)."
} }
+1
View File
@@ -7,6 +7,7 @@
"base_dc": 10, "base_dc": 10,
"requires_ranged_touch": true, "requires_ranged_touch": true,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
+1
View File
@@ -7,6 +7,7 @@
"base_dc": 10, "base_dc": 10,
"requires_ranged_touch": true, "requires_ranged_touch": true,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "damage", "type": "damage",
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "effects": [],
"description": "Conjures 4 gallons of clean water per level in an open space or container. Utility spell, no combat effect." "description": "Conjures 4 gallons of clean water per level in an open space or container. Utility spell, no combat effect."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "description": "A cantrip that detects magical auras in a 60-ft cone (2 rounds to identify strength and school). Utility spell, no combat effect."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "effects": [],
"description": "A cantrip that determines whether a creature, object, or area is poisoned, venomous, or afflicted by acid. Utility spell, no combat effect." "description": "A cantrip that determines whether a creature, object, or area is poisoned, venomous, or afflicted by acid. Utility spell, no combat effect."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "ref", "save": "ref",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "immediate", "cast_time": "immediate",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "long", "cast_time": "long",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
@@ -6,6 +6,7 @@
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
@@ -6,6 +6,7 @@
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "will", "save": "will",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "one_round", "cast_time": "one_round",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "none", "save": "none",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [], "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." "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."
} }
+1
View File
@@ -6,6 +6,7 @@
"save": "ref", "save": "ref",
"base_dc": 10, "base_dc": 10,
"cast_time": "standard", "cast_time": "standard",
"spell_resistance": false,
"effects": [ "effects": [
{ {
"type": "area", "type": "area",
+8 -2
View File
@@ -5478,6 +5478,7 @@ class CombatEngine:
if ( if (
target is not None target is not None
and not has_area and not has_area
and spell.spell_resistance
and not self._check_spell_resistance(caster, target) and not self._check_spell_resistance(caster, target)
): ):
return True, False return True, False
@@ -5735,7 +5736,7 @@ class CombatEngine:
maximize="Maximize Spell" in metamagic, maximize="Maximize Spell" in metamagic,
dc=dc, center_pos=center_pos, save_type=spell.save, dc=dc, center_pos=center_pos, save_type=spell.save,
crit=spell_crit, ranged_touch=per_projectile_attack, 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) save_str = _save_label(save_result)
if target is None and center_pos is not None: if target is None and center_pos is not None:
@@ -5794,6 +5795,7 @@ class CombatEngine:
crit: bool = False, crit: bool = False,
ranged_touch: bool = False, ranged_touch: bool = False,
attack_penalty: int = 0, attack_penalty: int = 0,
spell_resistance: bool = True,
) -> list[str]: ) -> list[str]:
saved = save_result is not None and save_result.success saved = save_result is not None and save_result.success
return [ return [
@@ -5803,6 +5805,7 @@ class CombatEngine:
empower=empower, maximize=maximize, saved=saved, empower=empower, maximize=maximize, saved=saved,
dc=dc, center_pos=center_pos, save_type=save_type, dc=dc, center_pos=center_pos, save_type=save_type,
crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty, crit=crit, ranged_touch=ranged_touch, attack_penalty=attack_penalty,
spell_resistance=spell_resistance,
) )
for effect in effects for effect in effects
] ]
@@ -5825,6 +5828,7 @@ class CombatEngine:
crit: bool = False, crit: bool = False,
ranged_touch: bool = False, ranged_touch: bool = False,
attack_penalty: int = 0, attack_penalty: int = 0,
spell_resistance: bool = True,
) -> str: ) -> str:
if isinstance(effect, AreaEffect): if isinstance(effect, AreaEffect):
return self._apply_area_effect( return self._apply_area_effect(
@@ -5832,6 +5836,7 @@ class CombatEngine:
caster=caster, spell_name=spell_name, saved=saved, caster=caster, spell_name=spell_name, saved=saved,
empower=empower, maximize=maximize, dc=dc, empower=empower, maximize=maximize, dc=dc,
center_pos=center_pos, save_type=save_type, center_pos=center_pos, save_type=save_type,
spell_resistance=spell_resistance,
) )
if isinstance(effect, SummonEffect): if isinstance(effect, SummonEffect):
return self._apply_summon_effect(effect, caster, caster_live, spell_name) return self._apply_summon_effect(effect, caster, caster_live, spell_name)
@@ -6100,6 +6105,7 @@ class CombatEngine:
dc: int = 15, dc: int = 15,
center_pos: tuple[int, int] | None = None, center_pos: tuple[int, int] | None = None,
save_type: SaveType = "none", save_type: SaveType = "none",
spell_resistance: bool = True,
) -> str: ) -> str:
"""Apply an area effect to all creatures within its template. """Apply an area effect to all creatures within its template.
@@ -6125,7 +6131,7 @@ 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): if spell_resistance and not self._check_spell_resistance(caster, affected_state):
continue 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,
+6
View File
@@ -238,6 +238,11 @@ class SpellSpec:
# area/save-only spells keep this False. # area/save-only spells keep this False.
requires_ranged_touch: bool = False requires_ranged_touch: bool = False
cast_time: CastTime = "standard" 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, ...] = () effects: tuple[SpellEffect, ...] = ()
description: str = "" description: str = ""
@@ -573,6 +578,7 @@ def load_spell(path: str | Path) -> SpellSpec:
base_dc=_as_int(data.get("base_dc", 10)), base_dc=_as_int(data.get("base_dc", 10)),
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)), requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
cast_time=cast_time, cast_time=cast_time,
spell_resistance=bool(data.get("spell_resistance", True)),
effects=effects, effects=effects,
description=_as_str(data.get("description", "")), description=_as_str(data.get("description", "")),
) )
+92
View File
@@ -230,6 +230,98 @@ def test_weapon_fire_damage_reduced_by_resistance() -> None:
assert fs.hp < 100 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: def test_snapshot_exposes_new_keys() -> None:
hero = _make_combatant( hero = _make_combatant(
"hero", "hero",