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