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