fix(rules): harmless spells on willing targets forgo the save roll - SpellSpec gains a data-driven 'harmless' flag set on the 29 SRD '(harmless)' spells (verified one by one against the KB); a same-side/self target consumes NO save die (SRD saving-throw.md: '(harmless) ... can attempt a saving throw if it desires' + 'Voluntarily Giving up a Saving Throw'), while hostile targets and undead receiving a cure (harmful to them, Will half for undead damage) keep the roll; snapshot spell summaries expose 'harmless'; golden transcript unchanged (no harmless-save cast in it); +8 tests, 3 fixtures re-pinned for the removed die
This commit is contained in:
@@ -1137,7 +1137,24 @@ Règles modélisées :
|
|||||||
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
sur un échec et **aucun dégât sur un succès** (la sauvegarde annule) ; le
|
||||||
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
demi-dégât sur sauvegarde réussie ne s'applique que là où l'effet le déclare
|
||||||
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
(`half_on_save: true` pour les rayons/lignes/cônes, `AreaEffect.save_for_half`
|
||||||
pour les explosions). Ciblage par point (U7b) : `Action(kind="cast_spell",
|
pour les explosions). **Sorts « harmless » et cible consentante** (SRD
|
||||||
|
« Spell Descriptions — Saving Throw » : « (harmless) : The spell is usually
|
||||||
|
beneficial, not harmful, but a targeted creature can attempt a saving throw
|
||||||
|
if it desires » ; « Voluntarily Giving up a Saving Throw : A creature can
|
||||||
|
voluntarily forego a saving throw and willingly accept a spell's result ») :
|
||||||
|
`SpellSpec` porte un booléen `harmless` (JSON `"harmless"`, défaut `false`)
|
||||||
|
posés sur les 29 sorts dont la fiche SRD marque la sauvegarde « (harmless) »
|
||||||
|
(vérifiés un à un contre le KB — les sorts SRD « save: none » comme Aid et
|
||||||
|
Bless n'ont pas la marque et n'en ont pas besoin). Jeté sur une cible
|
||||||
|
**consentante** (soi-même ou un même camp), le jet de sauvegarde primaire
|
||||||
|
est **totalement sauté** — aucun dé consommé, journalisé `NO SAVE`
|
||||||
|
(`_save_forgone`). Deux exceptions fidèles conservent le jet : une cible
|
||||||
|
**hostile** n'est pas consentante, et un sort de soin sur un **mort-vivant**
|
||||||
|
lui est **nuisible** (SRD cure-light-wounds.md : il « deals damage to them
|
||||||
|
instead » avec « Will half » pour demi-dégâts). Simplification documentée :
|
||||||
|
la « volonté » est approximée par le camp (le moteur n'a pas de concept
|
||||||
|
d'intention de cible) ; un sort harmless jeté sur un ennemi garde donc son
|
||||||
|
jet. Ciblage par point (U7b) : `Action(kind="cast_spell",
|
||||||
target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la
|
target_pos=(r, c))` centre l'`AreaEffect` de niveau supérieur du sort sur la
|
||||||
case `(r, c)` désignée au lieu de la position de la cible créature — poser un
|
case `(r, c)` désignée au lieu de la position de la cible créature — poser un
|
||||||
Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte
|
Fireball au sol, pas seulement sur une tête. Le point doit être dans la carte
|
||||||
@@ -1653,7 +1670,8 @@ Règles modélisées :
|
|||||||
feature_details (C4 : tuple de {name, category, source_detail, effects:
|
feature_details (C4 : tuple de {name, category, source_detail, effects:
|
||||||
tuple de {target, value, bonus_type, condition, weapon_filter}}),
|
tuple de {target, value, bonus_type, condition, weapon_filter}}),
|
||||||
spells (résumés enrichis du registre : name, level, school, range_ft,
|
spells (résumés enrichis du registre : name, level, school, range_ft,
|
||||||
save, dc, summary, half_on_save, save_negates (E4), area {shape,
|
save, dc, summary, half_on_save, save_negates (E4), harmless (sort «
|
||||||
|
(harmless) » du SRD — sauvegarde sautée sur cible consentante), area {shape,
|
||||||
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
|
radius_ft} | None (U7a : premier AreaEffect de niveau supérieur du sort,
|
||||||
gabarit de zone structuré, None si le sort n'a pas de zone), cast_time
|
gabarit de zone structuré, None si le sort n'a pas de zone), cast_time
|
||||||
(code de temps d'incantation, « standard » pour un sort inconnu du
|
(code de temps d'incantation, « standard » pour un sort inconnu du
|
||||||
@@ -1908,7 +1926,8 @@ Limites de sorts (conformance D6-D9, documentées plutôt que bricolées) :
|
|||||||
`AbilitySpec` réels ou des « no-op » tracés.
|
`AbilitySpec` réels ou des « no-op » tracés.
|
||||||
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
|
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
|
||||||
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
|
sauvegarde, DC de base, exigence de contact à distance `requires_ranged_touch`
|
||||||
(rayons), temps d'incantation `cast_time`) et types d'effets
|
(rayons), marque `harmless` (SRD « (harmless) » — sauvegarde sautée sur
|
||||||
|
cible consentante), temps d'incantation `cast_time`) et types d'effets
|
||||||
(`DamageEffect`, `ConditionEffect`,
|
(`DamageEffect`, `ConditionEffect`,
|
||||||
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
|
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
|
||||||
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
|
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "fort",
|
"save": "fort",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "fort",
|
"save": "fort",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "fort",
|
"save": "fort",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "one_round",
|
"cast_time": "one_round",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "divination",
|
"school": "divination",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "fort",
|
"save": "fort",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "divination",
|
"school": "divination",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "enchantment",
|
"school": "enchantment",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "long",
|
"cast_time": "long",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "long",
|
"cast_time": "long",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"spell_resistance": false,
|
"spell_resistance": false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"spell_resistance": false,
|
"spell_resistance": false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"spell_resistance": false,
|
"spell_resistance": false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"spell_resistance": false,
|
"spell_resistance": false,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "long",
|
"cast_time": "long",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "evocation",
|
"school": "evocation",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "immediate",
|
"cast_time": "immediate",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "conjuration",
|
"school": "conjuration",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "divination",
|
"school": "divination",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "abjuration",
|
"school": "abjuration",
|
||||||
"range": "close",
|
"range": "close",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "illusion",
|
"school": "illusion",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [
|
"effects": [
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"school": "transmutation",
|
"school": "transmutation",
|
||||||
"range": "touch",
|
"range": "touch",
|
||||||
"save": "will",
|
"save": "will",
|
||||||
|
"harmless": true,
|
||||||
"base_dc": 10,
|
"base_dc": 10,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
"effects": [],
|
"effects": [],
|
||||||
|
|||||||
@@ -6240,6 +6240,7 @@ class CombatEngine:
|
|||||||
"summary": "",
|
"summary": "",
|
||||||
"half_on_save": False,
|
"half_on_save": False,
|
||||||
"save_negates": False,
|
"save_negates": False,
|
||||||
|
"harmless": False,
|
||||||
"area": None,
|
"area": None,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
}
|
}
|
||||||
@@ -6254,6 +6255,7 @@ class CombatEngine:
|
|||||||
"summary": _spell_summary(spell),
|
"summary": _spell_summary(spell),
|
||||||
"half_on_save": half_on_save,
|
"half_on_save": half_on_save,
|
||||||
"save_negates": spell.save != "none" and not half_on_save,
|
"save_negates": spell.save != "none" and not half_on_save,
|
||||||
|
"harmless": spell.harmless,
|
||||||
"area": _spell_area_dict(spell),
|
"area": _spell_area_dict(spell),
|
||||||
"cast_time": spell.cast_time,
|
"cast_time": spell.cast_time,
|
||||||
}
|
}
|
||||||
@@ -6710,6 +6712,35 @@ class CombatEngine:
|
|||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def _save_forgone(
|
||||||
|
self, caster: CombatantState, target: CombatantState, spell: SpellSpec
|
||||||
|
) -> bool:
|
||||||
|
"""True when a willing target forgoes the save of a harmless spell.
|
||||||
|
|
||||||
|
PF1e (SRD "Spell Descriptions — Saving Throw",
|
||||||
|
rules/prpg-core-rulebook/saving-throw.md): "(harmless): The spell is
|
||||||
|
usually beneficial, not harmful, but a targeted creature can attempt
|
||||||
|
a saving throw if it desires" and "Voluntarily Giving up a Saving
|
||||||
|
Throw: A creature can voluntarily forego a saving throw and willingly
|
||||||
|
accept a spell's result". The engine's willingness proxy is the side:
|
||||||
|
self and same-side creatures are willing, so a spell flagged
|
||||||
|
``harmless`` rolled on them consumes NO save die. Two faithful
|
||||||
|
exceptions keep the roll: a hostile target is not willing (it may
|
||||||
|
attempt the save), and a cure spell is HARMFUL to an undead target
|
||||||
|
(SRD cure-light-wounds.md: it "deals damage to them instead of
|
||||||
|
curing their wounds [...] can attempt a Will save to take half
|
||||||
|
damage"), so an undead never forgoes that save.
|
||||||
|
"""
|
||||||
|
if not spell.harmless:
|
||||||
|
return False
|
||||||
|
if target is not caster and target.side != caster.side:
|
||||||
|
return False
|
||||||
|
return not any(
|
||||||
|
isinstance(effect, HealEffect)
|
||||||
|
and target.combatant.creature_type == "undead"
|
||||||
|
for effect in spell.effects
|
||||||
|
)
|
||||||
|
|
||||||
def _apply_and_log_cast(
|
def _apply_and_log_cast(
|
||||||
self,
|
self,
|
||||||
caster: CombatantState,
|
caster: CombatantState,
|
||||||
@@ -6744,6 +6775,7 @@ class CombatEngine:
|
|||||||
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
|
||||||
)
|
)
|
||||||
if spell.save != "none" and target is not None
|
if spell.save != "none" and target is not None
|
||||||
|
and not self._save_forgone(caster, target, spell)
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
caster_live = self._stats[caster.combatant.id]
|
caster_live = self._stats[caster.combatant.id]
|
||||||
|
|||||||
@@ -364,6 +364,14 @@ class SpellSpec:
|
|||||||
# no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check
|
# no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check
|
||||||
# entirely (targeted and per-creature in areas). Default True.
|
# entirely (targeted and per-creature in areas). Default True.
|
||||||
spell_resistance: bool = True
|
spell_resistance: bool = True
|
||||||
|
# PF1e (SRD, Magic — Spell Descriptions, saving throw notation):
|
||||||
|
# "(harmless): The spell is usually beneficial, not harmful, but a
|
||||||
|
# targeted creature can attempt a saving throw if it desires"
|
||||||
|
# (rules/prpg-core-rulebook/saving-throw.md). A willing target forgoes
|
||||||
|
# the save ("Voluntarily Giving up a Saving Throw: A creature can
|
||||||
|
# voluntarily forego a saving throw and willingly accept a spell's
|
||||||
|
# result"), so the engine skips the save roll on same-side targets.
|
||||||
|
harmless: bool = False
|
||||||
effects: tuple[SpellEffect, ...] = ()
|
effects: tuple[SpellEffect, ...] = ()
|
||||||
description: str = ""
|
description: str = ""
|
||||||
|
|
||||||
@@ -803,6 +811,7 @@ def load_spell(path: str | Path) -> SpellSpec:
|
|||||||
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)),
|
spell_resistance=bool(data.get("spell_resistance", True)),
|
||||||
|
harmless=bool(data.get("harmless", False)),
|
||||||
effects=effects,
|
effects=effects,
|
||||||
description=_as_str(data.get("description", "")),
|
description=_as_str(data.get("description", "")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
SRD "Cure Light Wounds" (spells/cure-light-wounds.md): "Since undead are
|
SRD "Cure Light Wounds" (spells/cure-light-wounds.md): "Since undead are
|
||||||
powered by negative energy, this spell deals damage to them instead of curing
|
powered by negative energy, this spell deals damage to them instead of curing
|
||||||
their wounds. An undead creature can apply spell resistance, and can attempt
|
their wounds. An undead creature can apply spell resistance, and can attempt a
|
||||||
a Will save to take half damage." The engine resolves SR upstream and the
|
Will save to take half damage." The engine resolves SR upstream and the
|
||||||
rolled Will save halves the damage here; living targets keep healing exactly
|
rolled Will save halves the damage here; living targets keep healing exactly
|
||||||
as before.
|
as before — and as a willing same-side target of a (harmless) spell a living
|
||||||
|
target forgoes the Will roll entirely (no die consumed).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -70,9 +71,12 @@ class TestCureDamagesUndead:
|
|||||||
def test_living_target_still_heals(self) -> None:
|
def test_living_target_still_heals(self) -> None:
|
||||||
"""Given: a living ally at 5/20 hp
|
"""Given: a living ally at 5/20 hp
|
||||||
When: cured with a die roll of 8
|
When: cured with a die roll of 8
|
||||||
Then: it heals 13 hp (behavior unchanged for the living)."""
|
Then: it heals 13 hp (behavior unchanged for the living). The ally is
|
||||||
|
a willing target of a (harmless) spell, so NO Will die is consumed
|
||||||
|
(SRD saving-throw.md: "(harmless)" + "Voluntarily Giving up a Saving
|
||||||
|
Throw") — the queue holds only the heal die."""
|
||||||
engine, c, t = _make_engine(
|
engine, c, t = _make_engine(
|
||||||
_caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players"
|
_caster(), _make_target(), [8], target_pos=(1, 0), target_side="players"
|
||||||
)
|
)
|
||||||
t.hp = 5
|
t.hp = 5
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
@@ -96,7 +100,7 @@ class TestCureUndeadAttrition:
|
|||||||
|
|
||||||
def test_living_heal_records_no_damage(self) -> None:
|
def test_living_heal_records_no_damage(self) -> None:
|
||||||
engine, c, t = _make_engine(
|
engine, c, t = _make_engine(
|
||||||
_caster(), _make_target(), [1, 8], target_pos=(1, 0), target_side="players"
|
_caster(), _make_target(), [8], target_pos=(1, 0), target_side="players"
|
||||||
)
|
)
|
||||||
t.hp = 5
|
t.hp = 5
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""Harmless spells on willing targets forgo the saving throw roll.
|
||||||
|
|
||||||
|
PF1e (SRD "Spell Descriptions — Saving Throw",
|
||||||
|
rules/prpg-core-rulebook/saving-throw.md): "(harmless): The spell is usually
|
||||||
|
beneficial, not harmful, but a targeted creature can attempt a saving throw
|
||||||
|
if it desires" and "Voluntarily Giving up a Saving Throw: A creature can
|
||||||
|
voluntarily forego a saving throw and willingly accept a spell's result".
|
||||||
|
The engine models willingness by side: self and same-side creatures are
|
||||||
|
willing, so a spell carrying the data flag ``harmless`` (set on the SRD
|
||||||
|
"(harmless)" spells) consumes NO save die on them. Hostile targets and undead
|
||||||
|
receiving a cure (harmful to them, SRD cure-light-wounds.md "deals damage to
|
||||||
|
them instead") keep the roll. Non-harmless spells keep the roll everywhere.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pf1e_simulator.rng import RngExhaustedError
|
||||||
|
from pf1e_simulator.spells import SPELLS_DIR, load_spell_registry
|
||||||
|
from tests.test_spells import _make_caster, _make_engine, _make_target
|
||||||
|
|
||||||
|
# The 29 registry spells whose SRD stat block marks the save "(harmless)"
|
||||||
|
# (verified one by one against the KB, 2026-09 conformance round).
|
||||||
|
SRD_HARMLESS_SPELLS: frozenset[str] = frozenset({
|
||||||
|
"Bear's Endurance", "Bull's Strength", "Cure Light Wounds",
|
||||||
|
"Cure Moderate Wounds", "Delay Disease", "Delay Poison", "Enhance Water",
|
||||||
|
"Enhanced Diplomacy", "Face of the Devourer", "Guidance", "Heroism",
|
||||||
|
"Hide from Undead", "Make Whole", "Mending", "Owl's Wisdom",
|
||||||
|
"Protection from Evil", "Protection from Evil, Communal",
|
||||||
|
"Protection from Law", "Protection from Law, Communal", "Resistance",
|
||||||
|
"Restoration, Lesser", "Saving Finale", "Shield of Faith", "Stabilize",
|
||||||
|
"Status", "Stave Off Corruption", "Suppress Charms and Compulsions",
|
||||||
|
"Vanish", "Weapon of Awe",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_harmless_flag_matches_srd_audit() -> None:
|
||||||
|
"""Given: the spell registry loaded from data/spells/
|
||||||
|
When: every spell's harmless flag is collected
|
||||||
|
Then: it matches exactly the SRD "(harmless)" audit — no more, no less."""
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
flagged = {name for name, spell in registry.items() if spell.harmless}
|
||||||
|
assert flagged == SRD_HARMLESS_SPELLS
|
||||||
|
|
||||||
|
|
||||||
|
def test_harmless_spell_data_defaults_to_false() -> None:
|
||||||
|
registry = load_spell_registry(SPELLS_DIR)
|
||||||
|
assert registry["Cure Light Wounds"].harmless is True
|
||||||
|
assert registry["Fireball"].harmless is False
|
||||||
|
assert registry["Inflict Light Wounds"].harmless is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_cure_on_willing_ally_consumes_no_save_die() -> None:
|
||||||
|
"""Given: a wounded living ally
|
||||||
|
When: Cure Light Wounds (Will half (harmless)) is cast on it
|
||||||
|
Then: the heal lands with zero dice consumed for the save — the queue
|
||||||
|
holds only the heal die, and the transcript logs NO SAVE."""
|
||||||
|
caster = _make_caster().model_copy(update={"spells": ["Cure Light Wounds"]})
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
caster, _make_target(hp_max=30), [8], target_pos=(1, 0),
|
||||||
|
target_side="players",
|
||||||
|
)
|
||||||
|
t.hp = 10
|
||||||
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
assert t.hp == 10 + (8 + 5) # 1d8=8 + caster-level 5 flat bonus
|
||||||
|
cast_lines = [line for line in engine._transcript if "cast Cure" in line]
|
||||||
|
assert any("NO SAVE" in line for line in cast_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_harmless_buff_on_self_consumes_no_dice_at_all() -> None:
|
||||||
|
"""Given: a caster shielding itself (Shield of Faith, Will negates
|
||||||
|
(harmless), range personal)
|
||||||
|
When: the spell is cast with an EMPTY dice queue
|
||||||
|
Then: no roll is attempted (a save die would raise RngExhaustedError) and
|
||||||
|
the buff is applied."""
|
||||||
|
caster = _make_caster().model_copy(update={"spells": ["Shield of Faith"]})
|
||||||
|
engine, c, _t = _make_engine(
|
||||||
|
caster, _make_target(), [], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, c, "Shield of Faith")
|
||||||
|
assert any(b.source == "spell: Shield of Faith" for b in c.active_buffs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_undead_ally_does_not_forgo_the_cure_save() -> None:
|
||||||
|
"""Given: an undead on the caster's own side
|
||||||
|
When: cured (the spell is HARMFUL to it: negative-energy damage)
|
||||||
|
Then: the Will roll still happens — d20=1 fails, full 13 damage (the
|
||||||
|
queue [1, 8] is consumed save-then-damage; a skipped save would feed the
|
||||||
|
1 to the damage die for 6 hp, not 7)."""
|
||||||
|
caster = _make_caster().model_copy(update={"spells": ["Cure Light Wounds"]})
|
||||||
|
undead = _make_target(hp_max=20).model_copy(update={"creature_type": "undead"})
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
caster, undead, [1, 8], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
assert t.hp == 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_hostile_target_of_harmless_spell_still_rolls() -> None:
|
||||||
|
"""Given: Suppress Charms and Compulsions (close range, Will negates
|
||||||
|
(harmless)) aimed at an enemy — not a willing target
|
||||||
|
When: the spell is delivered (no touch attack: the spell is not touch)
|
||||||
|
Then: the Will die is still consumed — a d20=1 scripted as a failed save
|
||||||
|
proves the roll happened (FAILED label)."""
|
||||||
|
caster = _make_caster().model_copy(
|
||||||
|
update={"spells": ["Suppress Charms and Compulsions"]}
|
||||||
|
)
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
caster, _make_target(), [1], target_pos=(5, 0), target_side="monsters"
|
||||||
|
)
|
||||||
|
# (5,0) is 25 ft: inside close range, outside melee - no casting AoO.
|
||||||
|
engine._cast_spell(c, t, "Suppress Charms and Compulsions")
|
||||||
|
cast_lines = [
|
||||||
|
line for line in engine._transcript if "cast Suppress" in line
|
||||||
|
]
|
||||||
|
assert any("FAILED" in line for line in cast_lines)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_harmless_spell_on_ally_still_rolls() -> None:
|
||||||
|
"""Given: Inflict Light Wounds (Will half, NOT harmless) aimed at a
|
||||||
|
living ally
|
||||||
|
When: delivered
|
||||||
|
Then: the save die is consumed — d20=1 fails (1+2 < DC 15) and the full
|
||||||
|
damage lands; had the roll been skipped the queue's 1 would have fed the
|
||||||
|
damage die instead (1d4=1+5 vs the scripted 4+5)."""
|
||||||
|
caster = _make_caster().model_copy(update={"spells": ["Inflict Light Wounds"]})
|
||||||
|
engine, c, t = _make_engine(
|
||||||
|
caster, _make_target(hp_max=30), [1, 4], target_pos=(1, 0),
|
||||||
|
target_side="players",
|
||||||
|
)
|
||||||
|
t.hp = 30
|
||||||
|
engine._cast_spell(c, t, "Inflict Light Wounds")
|
||||||
|
assert t.hp == 30 - (4 + 5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_forgone_save_never_breaks_when_queue_empty() -> None:
|
||||||
|
"""Sanity: the willing-target path with an EMPTY queue applies the buff;
|
||||||
|
the hostile path with the same queue raises (the save die is genuinely
|
||||||
|
gone). Suppress Charms is close range, so the hostile leg reaches the
|
||||||
|
save without a touch-attack die."""
|
||||||
|
caster = _make_caster().model_copy(update={"spells": ["Bull's Strength"]})
|
||||||
|
engine, c, _t = _make_engine(
|
||||||
|
caster, _make_target(), [], target_pos=(1, 0), target_side="players"
|
||||||
|
)
|
||||||
|
engine._cast_spell(c, c, "Bull's Strength")
|
||||||
|
suppress = _make_caster().model_copy(
|
||||||
|
update={"spells": ["Suppress Charms and Compulsions"]}
|
||||||
|
)
|
||||||
|
hostile, c2, t2 = _make_engine(
|
||||||
|
suppress, _make_target(), [], target_pos=(1, 0), target_side="monsters"
|
||||||
|
)
|
||||||
|
with pytest.raises(RngExhaustedError):
|
||||||
|
hostile._cast_spell(c2, t2, "Suppress Charms and Compulsions")
|
||||||
@@ -531,6 +531,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
|
|||||||
"summary": "",
|
"summary": "",
|
||||||
"half_on_save": False,
|
"half_on_save": False,
|
||||||
"save_negates": False,
|
"save_negates": False,
|
||||||
|
"harmless": False,
|
||||||
"area": None,
|
"area": None,
|
||||||
"cast_time": "standard",
|
"cast_time": "standard",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -127,7 +127,8 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
|
|||||||
"""Given: a known Fireball snapshot dict
|
"""Given: a known Fireball snapshot dict
|
||||||
When: all keys and values are checked
|
When: all keys and values are checked
|
||||||
Then: the nine pre-U7a keys are present with unchanged values, and the
|
Then: the nine pre-U7a keys are present with unchanged values, and the
|
||||||
exact key set is the old nine plus the additive "area" and "cast_time"."""
|
exact key set is the old nine plus the additive "area", "cast_time" and
|
||||||
|
"harmless"."""
|
||||||
s = _spell_dict_for(_FIREBALL, "Fireball")
|
s = _spell_dict_for(_FIREBALL, "Fireball")
|
||||||
assert s["name"] == "Fireball"
|
assert s["name"] == "Fireball"
|
||||||
assert s["level"] == 3
|
assert s["level"] == 3
|
||||||
@@ -140,6 +141,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
|
|||||||
assert s["summary"] == "area 20ft"
|
assert s["summary"] == "area 20ft"
|
||||||
assert s["half_on_save"] is True
|
assert s["half_on_save"] is True
|
||||||
assert s["save_negates"] is False
|
assert s["save_negates"] is False
|
||||||
|
assert s["harmless"] is False
|
||||||
assert s["cast_time"] == "standard"
|
assert s["cast_time"] == "standard"
|
||||||
assert set(s.keys()) == {
|
assert set(s.keys()) == {
|
||||||
"name",
|
"name",
|
||||||
@@ -151,6 +153,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
|
|||||||
"summary",
|
"summary",
|
||||||
"half_on_save",
|
"half_on_save",
|
||||||
"save_negates",
|
"save_negates",
|
||||||
|
"harmless",
|
||||||
"area",
|
"area",
|
||||||
"cast_time",
|
"cast_time",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -665,7 +665,10 @@ class TestCureLightWounds:
|
|||||||
def test_heals_damage(self) -> None:
|
def test_heals_damage(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
target = _make_target(hp_max=30)
|
||||||
engine, c, t = _make_engine(caster, target, [3, 5], target_pos=(1, 0),
|
# Willing same-side target of a (harmless) spell: no Will die is
|
||||||
|
# consumed (SRD saving-throw.md "(harmless)" + "Voluntarily Giving up
|
||||||
|
# a Saving Throw") — the queue holds only the heal die.
|
||||||
|
engine, c, t = _make_engine(caster, target, [5], target_pos=(1, 0),
|
||||||
target_side="players")
|
target_side="players")
|
||||||
t.hp = 10
|
t.hp = 10
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
@@ -676,7 +679,7 @@ class TestCureLightWounds:
|
|||||||
def test_heal_capped_at_max(self) -> None:
|
def test_heal_capped_at_max(self) -> None:
|
||||||
caster = _make_caster()
|
caster = _make_caster()
|
||||||
target = _make_target(hp_max=30)
|
target = _make_target(hp_max=30)
|
||||||
engine, c, t = _make_engine(caster, target, [3, 3, 6], target_pos=(1, 0),
|
engine, c, t = _make_engine(caster, target, [3], target_pos=(1, 0),
|
||||||
target_side="players")
|
target_side="players")
|
||||||
t.hp = 28
|
t.hp = 28
|
||||||
engine._cast_spell(c, t, "Cure Light Wounds")
|
engine._cast_spell(c, t, "Cure Light Wounds")
|
||||||
|
|||||||
Reference in New Issue
Block a user