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
+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")