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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user