feat(rules): alignment-gated protection from evil/law wards via attacker context

Add attacker-alignment modifier conditions (attacker_is_evil/lawful/good/
chaotic) evaluated against an optional attacker context threaded through
_stat_modifiers/_modifier_condition_holds, _attack_ac_mods (resolve_attack
AC path), resolve_save and _area_save (spell save paths), plus the fear
aura. Without a source in scope the condition is False, so existing rolls
are untouched (golden transcripts bit-identical).

Give the four protection_from_evil/law(+communal) JSONs their SRD buff:
+2 deflection to AC and +2 resistance on fort/ref/will, gated on the
attacker/caster alignment (SRD: bonuses apply 'against attacks or effects
created by evil creatures'); duration 1 min/level approximated as 10
rounds (engine convention). Descriptions document the unmodeled mental-
control suppression and communal duration-split clauses. Level/range/
save/SR verified against the KB.
This commit is contained in:
2026-09-20 12:57:29 +02:00
parent 6a18d456f6
commit 3be22b9c55
8 changed files with 452 additions and 22 deletions
+12 -3
View File
@@ -1035,11 +1035,20 @@ Règles modélisées :
- Modificateurs conditionnés : un `StatModifier` avec `condition=` n'est appliqué
que si la condition tient au moment de la résolution (`_modifier_condition_holds`) :
`target_within_30ft_ranged` (arme à distance à ≤ 30 ft), `axe` (arme dont le nom
contient « axe »), et les descripteurs de sauvegarde (`fear`, `cold`,
contient « axe »), les descripteurs de sauvegarde (`fear`, `cold`,
`electricity`, `fey`, `divination`, `poison`, `disease`, `mind_affecting`) — les
descripteurs sont portés par l'aura de terreur, les sorts (types de dégâts,
conditions de peur, écoles enchantement/divination) et les effets de zone. Toute
condition inconnue ou non supportée reste exclue (False).
conditions de peur, écoles enchantement/divination) et les effets de zone — et
les conditions d'alignement de la source (`attacker_is_evil`, `attacker_is_lawful`,
`attacker_is_good`, `attacker_is_chaotic`) évaluées contre l'attaquant / la
créature qui crée l'effet, passé en contexte `attacker` par la résolution
d'attaque (CA), les sauvegardes de sorts (chemin ciblé et zone) et l'aura de
terreur ; sans source en contexte, la condition est Fausse. C'est ce qui porte
les protections alignement-dépendantes du SRD (protection from evil/law :
« +2 deflection bonus to AC and a +2 resistance bonus on saves against attacks
or effects created by evil creatures » — jamais contre une source qui n'est pas
du côté aligné en face). Toute condition inconnue ou non supportée reste exclue
(False).
- Incantation : chaque `CombatantState` porte `spell_slots_left` (initialisé depuis
le champ `spell_slots` du JSON, ex. `{"1": 4, ...}`) — convention de données :
`spell_slots` est une valeur **finale** par rencontre et le moteur ne calcule
+13 -2
View File
@@ -8,6 +8,17 @@
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A barrier wards the subject from evil for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by evil creatures, renewed saves and suppression of mental control, and lawful summoned evil creatures cannot touch it. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_evil" },
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" }
],
"duration_rounds": 10
}
],
"description": "SRD spells/protection-from-evil.md: a barrier wards the subject from evil for 1 min./level. The subject gains a +2 deflection bonus to AC and a +2 resistance bonus on saves - the exact SRD wording: \"+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by evil creatures\" - so both bonuses are gated on the attacker/caster alignment (condition attacker_is_evil) and never apply against a creature that is not evil. Will negates (harmless), spell resistance no. Duration 1 min/level approximated as 10 rounds (engine convention for 1-minute durations). Not modeled: the SRD's second effect (immediate renewed saving throw with a +2 morale bonus against mental-control spells and effects, suppression for the duration, immunity to new attempts) - the engine has no mental-control tracking to suppress; and the third effect (evil summoned creatures cannot touch the warded creature) - the engine has no summoned-subtype contact restriction."
}
+13 -2
View File
@@ -8,6 +8,17 @@
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Functions like protection from evil (+2 deflection to AC and +2 resistance on saves vs evil creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_evil" },
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" },
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_evil" }
],
"duration_rounds": 10
}
],
"description": "SRD spells/protection-from-evil-communal.md: functions like protection from evil - each touched creature gains the +2 deflection bonus to AC and +2 resistance bonus on saves against attacks or effects created by evil creatures (gated on the attacker/caster alignment via condition attacker_is_evil, never applying against a non-evil source). Will negates (harmless), spell resistance no. Not modeled: the SRD duration split - \"you divide the duration in 1-minute intervals among the creatures touched\" - the engine gives every touched creature the same fixed duration as protection from evil (1 min/level approximated as 10 rounds each) instead of draining one shared pool; and the mental-control suppression clause of the base spell (see protection_from_evil.json)."
}
+13 -2
View File
@@ -8,6 +8,17 @@
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "A barrier wards the subject from lawful creatures for 1 min/level: +2 deflection bonus to AC and +2 resistance bonus on saves against attacks and effects created by lawful creatures, plus renewed saves against mental control by lawful creatures. Will negates (harmless). The engine has no alignment system, so alignment-restricted bonuses cannot be modeled and the spell has no mechanical effects here."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_lawful" },
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" }
],
"duration_rounds": 10
}
],
"description": "SRD spells/protection-from-law.md: functions like protection from evil, except the deflection and resistance bonuses apply against lawful creatures - \"+2 deflection bonus to AC and a +2 resistance bonus on saves against attacks or effects created by lawful creatures\" - so both bonuses are gated on the attacker/caster alignment (condition attacker_is_lawful) and never apply against a creature that is not lawful. Will negates (harmless), spell resistance no. Duration 1 min/level approximated as 10 rounds (engine convention for 1-minute durations). Not modeled: the renewed saving throw against mental control by lawful creatures and the lawful summoned creature contact restriction (same gaps as protection from evil)."
}
+13 -2
View File
@@ -8,6 +8,17 @@
"base_dc": 10,
"cast_time": "standard",
"spell_resistance": false,
"effects": [],
"description": "Functions like protection from law (+2 deflection to AC and +2 resistance on saves vs lawful creatures, protection from mental control), except the 1 min/level duration is divided in 1-minute intervals among the touched creatures. Will negates (harmless). Alignment-restricted bonuses and duration-sharing are not modeled, so the spell has no mechanical effects here."
"effects": [
{
"type": "buff",
"modifiers": [
{ "target": "ac", "value": 2, "bonus_type": "deflection", "condition": "attacker_is_lawful" },
{ "target": "fort", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
{ "target": "ref", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" },
{ "target": "will", "value": 2, "bonus_type": "resistance", "condition": "attacker_is_lawful" }
],
"duration_rounds": 10
}
],
"description": "SRD spells/protection-from-law-communal.md: functions like protection from law - each touched creature gains the +2 deflection bonus to AC and +2 resistance bonus on saves against attacks or effects created by lawful creatures (gated on the attacker/caster alignment via condition attacker_is_lawful, never applying against a non-lawful source). Will negates (harmless), spell resistance no. Not modeled: the SRD duration split - \"you divide the duration in 1-minute intervals among the creatures touched\" - the engine gives every touched creature the same fixed duration as protection from law (1 min/level approximated as 10 rounds each) instead of draining one shared pool; and the mental-control suppression clause of the base spell (see protection_from_law.json)."
}
+68 -10
View File
@@ -317,6 +317,18 @@ _SAVE_DESCRIPTOR_CONDITIONS = frozenset({
"mind_affecting",
})
# SRD "Alignment" axis predicates for the attacker-alignment modifier
# conditions (protection from evil/law/good/chaos: "+2 deflection bonus to AC
# and a +2 resistance bonus on saves against attacks or effects created by
# evil creatures"). Evaluated against the optional ``attacker`` context;
# without a source in scope the condition is False.
_ATTACKER_ALIGNMENT_CONDITIONS: dict[str, Callable[[Combatant], bool]] = {
"attacker_is_evil": lambda c: c.is_evil,
"attacker_is_lawful": lambda c: c.is_lawful,
"attacker_is_good": lambda c: c.is_good,
"attacker_is_chaotic": lambda c: c.is_chaotic,
}
_SIZE_MODIFIERS: dict[str, int] = {
"fine": -8, "diminutive": -4, "tiny": -2, "small": -1,
@@ -2216,13 +2228,20 @@ class CombatEngine:
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
attacker: CombatantState | None = None,
) -> bool:
"""Evaluate whether a StatModifier's runtime condition holds.
Unknown/unresolvable conditions return False (modifier excluded).
Supported: "target_within_30ft_ranged" (ranged-style weapon, <= 30 ft),
"axe" (weapon name contains "axe"), and save-descriptor names (fear,
cold, ...) that are present in the save's descriptor set.
"axe" (weapon name contains "axe"), save-descriptor names (fear,
cold, ...) that are present in the save's descriptor set, and the
attacker-alignment names "attacker_is_evil" / "attacker_is_lawful" /
"attacker_is_good" / "attacker_is_chaotic" (SRD protection from evil/
law/good/chaos: "+2 deflection bonus to AC and a +2 resistance bonus
on saves against attacks or effects created by evil creatures" - the
bonus checks the ALIGNMENT of the attacker/effect source, passed as
``attacker``; without a source in scope the condition is False).
"""
if condition_name == "target_within_30ft_ranged":
return (
@@ -2235,6 +2254,9 @@ class CombatEngine:
return weapon is not None and "axe" in weapon.name.lower()
if condition_name in _SAVE_DESCRIPTOR_CONDITIONS:
return condition_name in save_descriptors
alignment_holds = _ATTACKER_ALIGNMENT_CONDITIONS.get(condition_name)
if alignment_holds is not None:
return attacker is not None and alignment_holds(attacker.combatant)
return False
def _get_speed_mult(self, state: CombatantState) -> float:
@@ -2834,6 +2856,7 @@ class CombatEngine:
weapon: AttackSpec | None = None,
dist_ft: int | None = None,
save_descriptors: frozenset[str] = frozenset(),
attacker: CombatantState | None = None,
) -> list[StatModifier]:
"""Collect all StatModifiers for ``target`` from all sources.
@@ -2846,8 +2869,11 @@ class CombatEngine:
Modifiers whose ``condition`` is set are only included when the named
runtime condition holds for ``state`` (evaluated via
``_modifier_condition_holds``, which consumes the weapon/range/save
context threaded from the action being resolved). Unsupported
condition names are treated as NOT holding (modifier excluded).
context threaded from the action being resolved). ``attacker`` is the
source of the attack/effect being resolved (the alignment context for
the "attacker_is_*" conditions); None when no source is in scope.
Unsupported condition names are treated as NOT holding (modifier
excluded).
"""
mods: list[StatModifier] = []
# Old effects field (backwards compatibility)
@@ -2885,6 +2911,7 @@ class CombatEngine:
weapon=weapon,
dist_ft=dist_ft,
save_descriptors=save_descriptors,
attacker=attacker,
)
]
@@ -2977,7 +3004,11 @@ class CombatEngine:
if dist_ft > self._FEAR_AURA_RANGE_FT:
continue
save = self.resolve_save(
enemy, "will", self._FEAR_AURA_DC, save_descriptors=frozenset({"fear"})
enemy,
"will",
self._FEAR_AURA_DC,
save_descriptors=frozenset({"fear"}),
attacker=state,
)
if save.success:
self._log(
@@ -3021,10 +3052,19 @@ class CombatEngine:
return max(1, attacker.combatant.bab // 4 + 1)
def _attack_ac_mods(
self, target: CombatantState, weapon: AttackSpec
self,
target: CombatantState,
weapon: AttackSpec,
*,
attacker: CombatantState | None = None,
) -> list[StatModifier]:
"""AC modifiers for one attack — the weapon-kind-dependent ones included.
``attacker`` is the source of the attack, threaded to
``_stat_modifiers`` so alignment-gated AC modifiers (SRD protection
from evil/law/good/chaos: "+2 deflection bonus to AC ... against
attacks made ... by evil creatures") can evaluate their condition.
PF1e (CRB): a prone defender takes -4 AC against melee attacks but
gains +4 AC against ranged attacks; a helpless defender (paralyzed,
asleep, unconscious, helpless) has an effective Dexterity score of 0,
@@ -3033,7 +3073,7 @@ class CombatEngine:
so the mod is exactly -5 there; on the touch base the stored Dex
modifier is stripped at the base (see resolve_attack) before this
-5 is added."""
mods = self._stat_modifiers(target, "ac")
mods = self._stat_modifiers(target, "ac", attacker=attacker)
if self._has_condition_named(target, "prone"):
mods.append(
StatModifier(
@@ -3140,7 +3180,7 @@ class CombatEngine:
base_ac = target.combatant.ac.touch
else:
base_ac = full_ac_base
ac_mods = self._attack_ac_mods(target, weapon)
ac_mods = self._attack_ac_mods(target, weapon, attacker=attacker)
ac = base_ac + resolve_modifiers(ac_mods)
ac += target.defensive_ac_bonus
ac += target.aid_ac_bonus
@@ -3617,6 +3657,7 @@ class CombatEngine:
*,
save_descriptors: frozenset[str] = frozenset(),
flat_bonus: int = 0,
attacker: CombatantState | None = None,
) -> SaveResult:
"""Roll a saving throw (fort/ref/will) against ``dc``.
@@ -3628,11 +3669,16 @@ class CombatEngine:
``flat_bonus`` is an untyped situational bonus added to the total
(used for the cover bonus on Reflex saves, CRB "Cover and Reflex
Saves"); natural 1/20 still override the roll regardless of it.
``attacker`` is the creature that created the effect being saved
against (the alignment context for the "attacker_is_*" conditions,
SRD protection from evil: "+2 resistance bonus on saves ... against
... effects created by evil creatures"); None when no source is in
scope.
"""
roll = self._rng.d20()
base = getattr(state.combatant.saves, save_type)
save_mods = self._stat_modifiers(
state, save_type, save_descriptors=save_descriptors
state, save_type, save_descriptors=save_descriptors, attacker=attacker
)
# PF1e one-shot save bonus (SRD spells/guidance.md, save track): the
# first save of the bearer discharges it (stacked by bonus type).
@@ -7024,7 +7070,11 @@ class CombatEngine:
dc = self._spell_dc(caster, spell, slot_level=slot_level, metamagic=metamagic)
save_result = (
self.resolve_save(
target, spell.save, dc, save_descriptors=spell_save_descriptors(spell)
target,
spell.save,
dc,
save_descriptors=spell_save_descriptors(spell),
attacker=caster,
)
if spell.save != "none" and target is not None
and not _spell_centered_on_caster(spell)
@@ -7791,6 +7841,7 @@ class CombatEngine:
affected_state, None if effect.centered_on_caster else target,
effect, saved=saved, dc=dc,
save_type=save_type, origin_cells=origin_cells,
attacker=caster,
)
for sub_effect in effect.effects:
target_live = self._stats[affected_state.combatant.id]
@@ -7912,6 +7963,7 @@ class CombatEngine:
dc: int = 15,
save_type: SaveType = "none",
origin_cells: frozenset[Pos] = frozenset(),
attacker: CombatantState | None = None,
) -> bool:
"""Save result for one creature in an area effect.
@@ -7923,6 +7975,11 @@ class CombatEngine:
other creature in the area - and every creature of a point-targeted
burst (no primary target, U7b) - rolls its own.
``attacker`` is the caster who created the area effect, threaded to
``resolve_save`` so alignment-gated save bonuses (SRD protection from
evil: "+2 resistance bonus on saves ... against ... effects created by
evil creatures") can evaluate their condition.
PF1e (CRB "Cover and Reflex Saves"): cover grants a Reflex bonus
against effects that burst from a point on the other side of the
cover - half the AC value (+1 partial / +2 standard / +4 improved);
@@ -7949,6 +8006,7 @@ class CombatEngine:
dc,
save_descriptors=save_descriptors_for_effects(effect.effects),
flat_bonus=flat_bonus,
attacker=attacker,
)
return save_result.success
+129 -1
View File
@@ -39,7 +39,7 @@ from pf1e_simulator.abilities import (
)
from pf1e_simulator.combat import CombatantState, CombatEngine, SaveResult
from pf1e_simulator.dice import parse_dice
from pf1e_simulator.effects import StatModifier
from pf1e_simulator.effects import ActiveBuff, StatModifier
from pf1e_simulator.grid import Grid
from pf1e_simulator.map import MapSpec, TerrainType
from pf1e_simulator.models import (
@@ -508,3 +508,131 @@ def test_spell_save_descriptors_derivation(spell_name: str, expected: frozenset[
"""Damage types, fear conditions and school tags map to save descriptors."""
reg = load_spell_registry(_SPELLS_DIR)
assert spell_save_descriptors(reg[spell_name]) == expected
# ── Attacker-alignment conditions (SRD protection from X) ─────────────────────
def _aligned(cid: str, alignment: str) -> CombatantState:
hero = make_combatant(cid)
combatant = hero.model_copy(update={"alignment": alignment})
return make_state(combatant, "monsters", (5, 5))
@pytest.mark.parametrize(
("condition", "alignment"),
[
("attacker_is_evil", "CE"),
("attacker_is_evil", "LE"),
("attacker_is_evil", "NE"),
("attacker_is_lawful", "LG"),
("attacker_is_lawful", "LN"),
("attacker_is_lawful", "LE"),
("attacker_is_good", "LG"),
("attacker_is_good", "NG"),
("attacker_is_good", "CG"),
("attacker_is_chaotic", "CG"),
("attacker_is_chaotic", "CN"),
("attacker_is_chaotic", "CE"),
],
)
def test_attacker_alignment_condition_vocabulary_holds(
condition: str, alignment: str
) -> None:
"""The attacker_is_* names hold when the attacker's axis matches."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
attacker = _aligned("attacker", alignment)
assert engine._modifier_condition_holds(h, condition, attacker=attacker) is True
@pytest.mark.parametrize(
("condition", "alignment"),
[
("attacker_is_evil", "LG"),
("attacker_is_evil", "N"),
("attacker_is_lawful", "CE"),
("attacker_is_lawful", "N"),
("attacker_is_good", "NE"),
("attacker_is_good", "N"),
("attacker_is_chaotic", "LN"),
("attacker_is_chaotic", "N"),
],
)
def test_attacker_alignment_condition_vocabulary_fails(
condition: str, alignment: str
) -> None:
"""The attacker_is_* names fail when the attacker's axis does not match."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
attacker = _aligned("attacker", alignment)
assert engine._modifier_condition_holds(h, condition, attacker=attacker) is False
def test_attacker_alignment_condition_false_without_attacker() -> None:
"""No attacker in scope -> the alignment condition is False (modifier excluded)."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
engine = make_engine([1], [h])
for condition in (
"attacker_is_evil",
"attacker_is_lawful",
"attacker_is_good",
"attacker_is_chaotic",
):
assert engine._modifier_condition_holds(h, condition) is False
def test_attacker_alignment_condition_on_attack_ac() -> None:
"""End-to-end: an attacker_is_evil AC modifier applies only vs an evil attacker."""
hero = make_combatant("hero", ac_total=16)
h = make_state(hero, "players", (1, 1))
h.active_buffs.append(
ActiveBuff(
modifiers=(
StatModifier(target="ac", value=2, bonus_type="deflection",
condition="attacker_is_evil"),
),
rounds_left=10,
source="spell: probe",
)
)
evil = make_combatant("evil", hp=40)
e = evil.model_copy(update={"alignment": "CE"})
es = make_state(e, "monsters", (1, 2))
neutral = make_combatant("neutral", hp=40)
ns = make_state(neutral, "monsters", (1, 2))
engine = make_engine([2, 2], [h, es, ns])
evil_result = engine.resolve_attack(es, h, evil.attacks[0])
neutral_result = engine.resolve_attack(ns, h, neutral.attacks[0])
assert evil_result.ac == 18 # 16 + 2 deflection vs the evil attacker
assert neutral_result.ac == 16 # no bonus against a neutral attacker
def test_attacker_alignment_condition_on_save() -> None:
"""End-to-end: an attacker_is_lawful save modifier applies only vs a lawful source."""
hero = make_combatant("hero")
h = make_state(hero, "players", (0, 0))
h.active_buffs.append(
ActiveBuff(
modifiers=(
StatModifier(target="will", value=2, bonus_type="resistance",
condition="attacker_is_lawful"),
),
rounds_left=10,
source="spell: probe",
)
)
lawful = make_combatant("lawful")
ls = make_state(lawful.model_copy(update={"alignment": "LG"}), "monsters", (5, 5))
chaotic = make_combatant("chaotic")
cs = make_state(chaotic.model_copy(update={"alignment": "CE"}), "monsters", (5, 5))
engine = make_engine([10, 10, 10], [h])
vs_lawful = engine.resolve_save(h, "will", 25, attacker=ls)
vs_chaotic = engine.resolve_save(h, "will", 25, attacker=cs)
no_source = engine.resolve_save(h, "will", 25)
assert vs_lawful.total == 12 # 10 + 2 resistance vs the lawful source
assert vs_chaotic.total == 10 # CE is not lawful
assert no_source.total == 10 # no attacker in scope -> condition False
+191
View File
@@ -0,0 +1,191 @@
"""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