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:
@@ -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")
|
||||
|
||||
@@ -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": "",
|
||||
"half_on_save": False,
|
||||
"save_negates": False,
|
||||
"harmless": False,
|
||||
"area": None,
|
||||
"cast_time": "standard",
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user