155 lines
7.1 KiB
Python
155 lines
7.1 KiB
Python
"""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")
|