"""Protection from Evil / Law (and Communal variants): alignment-gated wards. SRD spells/protection-from-evil.md: "+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by evil creatures"; spells/protection-from-law.md: same against lawful creatures. The bonuses are carried as condition-gated StatModifiers (attacker_is_evil / attacker_is_lawful) and only apply when the attack/effect source's alignment matches; a neutral (or opposing-axis) source sees no bonus at all. Casting tests pin the buff landing on a willing ally (harmless, no save), the AC gate through resolve_attack, the save gate through resolve_save, and the communal variants sharing the exact same effect. Not modeled (documented in the JSON descriptions): the mental-control suppression clause, the summoned-creature contact restriction, and the communal 1-minute-interval duration split. """ from __future__ import annotations from pathlib import Path from typing import TYPE_CHECKING import pytest from pf1e_simulator.combat import CombatEngine from pf1e_simulator.rng import ScriptedRng from pf1e_simulator.spells import BuffEffect, load_spell from tests.test_observation_enriched import make_combatant, make_grid, make_state if TYPE_CHECKING: from pf1e_simulator.combat import CombatantState from pf1e_simulator.models import Combatant from pf1e_simulator.spells import SpellSpec DATA_SPELLS = Path(__file__).resolve().parent.parent / "data" / "spells" def _loaded(stem: str) -> SpellSpec: return load_spell(DATA_SPELLS / f"{stem}.json") def _aligned(cid: str, alignment: str, *, hp: int = 40) -> Combatant: return make_combatant(cid, hp=hp).model_copy(update={"alignment": alignment}) def _cast(stem: str, slot_level: int) -> tuple[CombatEngine, CombatantState]: """Cast a harmless protection spell on a willing ally. Returns the engine and the warded ally's state. The cast consumes no rolls (same-side harmless -> save forgone; spell_resistance false); the scripted 2s cover the deliberate-miss attack rolls and the saves below (attack bonus 5: 2+5=7 misses AC 15/17; saves base 0: total 2 or 2+2). """ spell = _loaded(stem) caster_cb = make_combatant( "caster", spells=[spell.name], spell_slots={slot_level: 1} ) ward_cb = make_combatant("ward", hp=40, ac=15) c = make_state(caster_cb, "players", (0, 0)) w = make_state(ward_cb, "players", (1, 0)) engine = CombatEngine( ScriptedRng([2] * 24), make_grid(), [c, w], spell_registry={spell.name: spell}, ) engine._cast_spell(c, w, spell.name) return engine, w def _ac_vs(engine: CombatEngine, ward: CombatantState, alignment: str) -> int: """Resolve a deliberate-miss attack from an ``alignment`` foe; return AC.""" foe_cb = _aligned("foe", alignment) f = make_state(foe_cb, "monsters", (2, 0)) result = engine.resolve_attack(f, ward, foe_cb.attacks[0]) assert result.hit is False return result.ac @pytest.mark.parametrize( ("stem", "slot_level"), [ ("protection_from_evil", 1), ("protection_from_evil_communal", 2), ("protection_from_law", 1), ("protection_from_law_communal", 2), ], ) def test_spell_carries_the_gated_buff(stem: str, slot_level: int) -> None: """Given: the shipped protection JSON When: load_spell parses it Then: one buff carries +2 deflection AC and +2 resistance on all three saves, all gated on the attacker's alignment, 10 rounds (1 min/level).""" spell = _loaded(stem) assert spell.level == slot_level (effect,) = spell.effects assert isinstance(effect, BuffEffect) assert effect.duration_rounds == 10 condition = "attacker_is_lawful" if "law" in stem else "attacker_is_evil" by_target = {m.target: m for m in effect.modifiers} assert set(by_target) == {"ac", "fort", "ref", "will"} assert by_target["ac"].value == 2 assert by_target["ac"].bonus_type == "deflection" for save in ("fort", "ref", "will"): assert by_target[save].value == 2 assert by_target[save].bonus_type == "resistance" for mod in effect.modifiers: assert mod.condition == condition def test_cast_lands_buff_on_willing_ally() -> None: """Casting PFE on a same-side ally applies the buff with no save roll.""" _, w = _cast("protection_from_evil", 1) (buff,) = w.active_buffs assert buff.source.startswith("spell:") assert buff.rounds_left == 10 assert len(buff.modifiers) == 4 def test_pfe_no_bonus_vs_neutral_attacker() -> None: """Neutral source: the ward is inert - AC stays at base.""" engine, w = _cast("protection_from_evil", 1) assert _ac_vs(engine, w, "N") == 15 def test_pfe_ac_bonus_vs_evil_attacker() -> None: """Evil attacker: +2 deflection to AC (SRD: the bonus applies against attacks made by evil creatures).""" engine, w = _cast("protection_from_evil", 1) assert _ac_vs(engine, w, "CE") == 17 assert _ac_vs(engine, w, "LE") == 17 def test_pfe_no_ac_bonus_vs_lawful_good_attacker() -> None: """LG is not evil: no AC bonus from protection from evil.""" engine, w = _cast("protection_from_evil", 1) assert _ac_vs(engine, w, "LG") == 15 def test_pfe_save_bonus_vs_evil_attacker_only() -> None: """+2 resistance on fort/ref/will vs an evil source; nothing vs a good source; nothing when no source is in scope.""" engine, w = _cast("protection_from_evil", 1) evil = make_state(_aligned("evil", "NE"), "monsters", (5, 5)) good = make_state(_aligned("good", "CG"), "monsters", (5, 5)) for save in ("fort", "ref", "will"): vs_evil = engine.resolve_save(w, save, 30, attacker=evil) vs_good = engine.resolve_save(w, save, 30, attacker=good) no_source = engine.resolve_save(w, save, 30) assert vs_evil.total == 4 # scripted d20 2 + base 0 + 2 resistance assert vs_good.total == 2 assert no_source.total == 2 def test_pfl_vice_versus() -> None: """Protection from Law: +2 AC and +2 saves vs lawful sources only; protection from evil stays inert against the same lawful-but-not-evil attacker (the gates are exact opposites on the good-evil axis).""" engine, w = _cast("protection_from_law", 1) assert _ac_vs(engine, w, "LG") == 17 assert _ac_vs(engine, w, "LN") == 17 assert _ac_vs(engine, w, "CE") == 15 assert _ac_vs(engine, w, "N") == 15 lawful = make_state(_aligned("lawful", "LN"), "monsters", (5, 5)) chaotic = make_state(_aligned("chaotic", "CN"), "monsters", (5, 5)) will_vs_lawful = engine.resolve_save(w, "will", 30, attacker=lawful) will_vs_chaotic = engine.resolve_save(w, "will", 30, attacker=chaotic) assert will_vs_lawful.total == 4 assert will_vs_chaotic.total == 2 def test_lawful_evil_attacker_triggers_both_wards() -> None: """LE is on both gates: an LE attacker meets evil AND lawful.""" engine_pfe, w_pfe = _cast("protection_from_evil", 1) engine_pfl, w_pfl = _cast("protection_from_law", 1) assert _ac_vs(engine_pfe, w_pfe, "LE") == 17 assert _ac_vs(engine_pfl, w_pfl, "LE") == 17 def test_communal_variants_share_the_effect() -> None: """The communal spells carry the same gated bonuses as their base spell (the 1-minute-interval duration split is documented as not modeled).""" engine, w = _cast("protection_from_evil_communal", 2) assert _ac_vs(engine, w, "CE") == 17 assert _ac_vs(engine, w, "N") == 15 evil = make_state(_aligned("evil", "CE"), "monsters", (5, 5)) assert engine.resolve_save(w, "will", 30, attacker=evil).total == 4 engine_law, w_law = _cast("protection_from_law_communal", 2) assert _ac_vs(engine_law, w_law, "LG") == 17 assert _ac_vs(engine_law, w_law, "CE") == 15