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:
2026-09-19 22:27:46 +02:00
parent ea835293a0
commit 4665a1c245
37 changed files with 266 additions and 12 deletions
+22 -3
View File
@@ -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
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`
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
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
@@ -1653,7 +1670,8 @@ Règles modélisées :
feature_details (C4 : tuple de {name, category, source_detail, effects:
tuple de {target, value, bonus_type, condition, weapon_filter}}),
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,
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
@@ -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.
- `spells.py` — sorts : `SpellSpec` (dataclass avec niveau, école, portée,
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`,
`HealEffect`, `BuffEffect`, `AreaEffect` — burst/cône/ligne, S1a). Un
`DamageEffect` (et un `HealEffect`, S5b) porte des specs de scalage
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "touch",
"save": "fort",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "touch",
"save": "fort",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "fort",
"harmless": true,
"base_dc": 10,
"cast_time": "one_round",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "divination",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "fort",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "divination",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "enchantment",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "close",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "long",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "close",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "long",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "long",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "evocation",
"range": "close",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "immediate",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "conjuration",
"range": "close",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "divination",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
@@ -4,6 +4,7 @@
"school": "abjuration",
"range": "close",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+1
View File
@@ -4,6 +4,7 @@
"school": "illusion",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [
+1
View File
@@ -4,6 +4,7 @@
"school": "transmutation",
"range": "touch",
"save": "will",
"harmless": true,
"base_dc": 10,
"cast_time": "standard",
"effects": [],
+32
View File
@@ -6240,6 +6240,7 @@ class CombatEngine:
"summary": "",
"half_on_save": False,
"save_negates": False,
"harmless": False,
"area": None,
"cast_time": "standard",
}
@@ -6254,6 +6255,7 @@ class CombatEngine:
"summary": _spell_summary(spell),
"half_on_save": half_on_save,
"save_negates": spell.save != "none" and not half_on_save,
"harmless": spell.harmless,
"area": _spell_area_dict(spell),
"cast_time": spell.cast_time,
}
@@ -6710,6 +6712,35 @@ class CombatEngine:
return False
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(
self,
caster: CombatantState,
@@ -6744,6 +6775,7 @@ class CombatEngine:
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
)
if spell.save != "none" and target is not None
and not self._save_forgone(caster, target, spell)
else None
)
caster_live = self._stats[caster.combatant.id]
+9
View File
@@ -364,6 +364,14 @@ class SpellSpec:
# no" (Acid Arrow, Flurry of Snowballs, ...). False = skip the SR check
# entirely (targeted and per-creature in areas). Default 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, ...] = ()
description: str = ""
@@ -803,6 +811,7 @@ def load_spell(path: str | Path) -> SpellSpec:
requires_ranged_touch=bool(data.get("requires_ranged_touch", False)),
cast_time=cast_time,
spell_resistance=bool(data.get("spell_resistance", True)),
harmless=bool(data.get("harmless", False)),
effects=effects,
description=_as_str(data.get("description", "")),
)
+10 -6
View File
@@ -2,10 +2,11 @@
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
their wounds. An undead creature can apply spell resistance, and can attempt
a Will save to take half damage." The engine resolves SR upstream and the
their wounds. An undead creature can apply spell resistance, and can attempt a
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
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
@@ -70,9 +71,12 @@ class TestCureDamagesUndead:
def test_living_target_still_heals(self) -> None:
"""Given: a living ally at 5/20 hp
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(
_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
engine._cast_spell(c, t, "Cure Light Wounds")
@@ -96,7 +100,7 @@ class TestCureUndeadAttrition:
def test_living_heal_records_no_damage(self) -> None:
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
engine._cast_spell(c, t, "Cure Light Wounds")
+154
View File
@@ -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")
+1
View File
@@ -531,6 +531,7 @@ def test_observation_enriched_spells_unknown_graceful() -> None:
"summary": "",
"half_on_save": False,
"save_negates": False,
"harmless": False,
"area": None,
"cast_time": "standard",
},
+4 -1
View File
@@ -127,7 +127,8 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
"""Given: a known Fireball snapshot dict
When: all keys and values are checked
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")
assert s["name"] == "Fireball"
assert s["level"] == 3
@@ -140,6 +141,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
assert s["summary"] == "area 20ft"
assert s["half_on_save"] is True
assert s["save_negates"] is False
assert s["harmless"] is False
assert s["cast_time"] == "standard"
assert set(s.keys()) == {
"name",
@@ -151,6 +153,7 @@ def test_area_snapshot_existing_keys_unchanged() -> None:
"summary",
"half_on_save",
"save_negates",
"harmless",
"area",
"cast_time",
}
+5 -2
View File
@@ -665,7 +665,10 @@ class TestCureLightWounds:
def test_heals_damage(self) -> None:
caster = _make_caster()
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")
t.hp = 10
engine._cast_spell(c, t, "Cure Light Wounds")
@@ -676,7 +679,7 @@ class TestCureLightWounds:
def test_heal_capped_at_max(self) -> None:
caster = _make_caster()
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")
t.hp = 28
engine._cast_spell(c, t, "Cure Light Wounds")